Skip to content

Hebrew and Arabic runs take the Latin font and size: w:szCs and w:rFonts/@cs are never read #3994

Description

@Nathaniel-260

What happened?

First, thank you — the engagement on #3959 and #3983 has been real, and the code around
this area is a pleasure to read: RunScriptContext in direction-context.ts states the
ECMA complex-script rule more clearly than the spec does. This issue is the half of that
rule that is still open.

Per ECMA-376 Annex I, a run resolving through the complex-script stack takes four
properties from the CS variants: bold from w:bCs, italic from w:iCs, size from
w:szCs, and font from w:rFonts/@cs
. #3959 (and PR #3958) covers the first two. The
other two are the ones that decide what the text looks like at all, and nothing reads
them — so a Hebrew or Arabic run is laid out and painted with the font and the size
belonging to the other script.

Where it stops, in the open tree (main @ 9ae9f03)

packages/layout-engine/style-engine/src/normalize/run-attrs.ts:

  • Size. Line 80 is const fontSize = resolveFontSize(props.fontSize); — the Latin
    w:sz, and only it. RunProperties.fontSizeCs is declared at
    style-engine/src/ooxml/types.ts:322 ("Complex script font size in half-points") and is
    read nowhere in the package.
  • Font. resolveFontFamily (line 139) walks
    ascii → hAnsi → asciiTheme/hAnsiTheme → eastAsia → eastAsiaTheme → cs → cstheme → val,
    so the cs slot at line 165 is reached only when the entire Latin track resolved to
    nothing. The function's own comment says as much — the ascii/hAnsi track is "used by
    Latin text — the only thing v2 paints generically right now".
  • And there is nowhere for a per-script value to travel. TextRunStyleAttrs
    (style-engine/src/normalize/types.ts:24-25) carries one fontFamily and one
    fontSize, so the stack has to be chosen inside the normalizer — exactly where fix(style-engine): resolve bold and italic from the complex-script toggles #3958
    now chooses it for bold and italic.

The contract already documents this as unfinished work, in RunScriptContext
(packages/layout-engine/contracts/src/direction-context.ts:150-160):

Per ECMA Annex I, when w:rtl is set or w:cs is set, the run's formatting comes from the
complex-script variants (bCs, iCs, szCs, rFonts/@cs). Otherwise it comes from the Latin
variants (b, i, sz, rFonts/@Ascii).

This context is preservation-only in Wave 1a. Wave 1b implements the stack-selection
logic (resolveRunScriptContext returns whether to render with the CS or Latin stack).

resolveRunScriptContext appears in that comment and nowhere else in the repository.

Why this bites harder than the bold leg

The two size slots usually hold different values in a real Hebrew document, because
Word keeps two independent size boxes in its font dialog and the Hebrew one is the one the
author sets. The heading style I quoted in #3959 has precisely that shape:

<w:rPr>
  <w:rFonts w:ascii="FrankRuehl DP" w:hAnsi="FrankRuehl DP" w:cs="FrankRuehl DP"/>
  <w:bCs/>
  <w:szCs w:val="28"/>          <!-- no w:sz at all -->
</w:rPr>

heading 2 there carries a CS size and no Latin size. With the Latin track as the only
source, the heading takes its size from whatever the cascade hands the Latin slot —
docDefaults — and comes out at body size. Bold is the second thing you notice about that
document; the size is the first.

The font slots diverge just as often, in the other direction: Word's Hebrew UI writes the
Hebrew face into w:cs and frequently leaves an unrelated Latin face in
w:ascii/w:hAnsi. The Hebrew glyphs are then shaped in a font that was never chosen for
them — and where that face has no Hebrew coverage, in whatever the browser substitutes.

This is not paint-only. run-attrs feeds the size that layout measures, so line breaking
follows the wrong value too.

Steps to reproduce

cs-stack.docx from this gist — synthetic, generated from scratch, no dependencies,
Node 18+:

https://gist.github.com/Nathaniel-260/a95fb34889cf3077726f22c3d1109952

node superdoc-run-property-repro.mjs . --case cs-stack

It puts 8pt Courier New in the Latin slots against 28pt Arial in the CS slots, so which
slot won is visible at a glance rather than something you have to measure. Six paragraphs,
each captioned in Latin with what Word does:

# run properties Word with the Latin track as the only source
1 sz=16 szCs=56, rFonts ascii="Courier New" cs="Arial", w:rtl Arial, 28pt Courier New, 8pt
2 sz=16 szCs=56, w:rtl, no rFonts 28pt 8pt
3 rFonts ascii="Courier New" cs="Arial", one size in both slots, w:rtl Arial Courier New
4 same as 1, selected by w:cs instead of w:rtl (§17.3.2.7) Arial, 28pt Courier New, 8pt
5 same as 1, carried by a paragraph style rather than direct formatting Arial, 28pt Courier New, 8pt
6 control — Latin run, same sz/szCs pair, neither w:rtl nor w:cs Courier New, 8pt Courier New, 8pt

Row 6 is the regression guard. Absence of both signals is not false: the stack then
follows the Unicode script of the run text, which this layer never sees, so a fix must
leave that line exactly as it is — the same reasoning usesComplexScriptToggles spells out
in #3958.

From Word instead of the gist: type a Hebrew line, set the complex-script size box in
Font to something far from the Latin size box, save, open.

SuperDoc version

2.13.0 (@superdoc/docx-engine 0.12.0). Also main @ 9ae9f03 (2.14.0-next.2).

Browser

Chrome

Additional context

Where this sits against the open work: #3959 / PR #3958 fix the bold and italic legs of the
same stack, and the usesComplexScriptToggles(props) helper that PR adds is the natural
place to hang these two — props.fontSizeCs ?? props.fontSize for the size, and a CS-first
slot order for the font, each keeping the Latin value as fallback for the reason documented
there (Word decides the stack per character, and plenty of documents carry only the Latin
variant on an RTL run).

Happy to send that as a PR — on top of #3958, or standalone if you would rather land the
two changes separately. Just say which.

Apologies for the number of issues from me lately. I am building a Hebrew editor on
SuperDoc and this stack is the floor of it, so I keep landing on it. I am a student on an
old two-core laptop and each round of verification takes a while, so if you tell me which
of these is most useful to you I will do that one first.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

status: queuedEngineering work is queued; no delivery date is committed.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions