Skip to content

Draw right-to-left (Hebrew) text in visual order - #884

Merged
julianhille merged 10 commits into
developfrom
feature/330-rtl-hebrew-support
Oct 14, 2026
Merged

julianhille merged 10 commits into
developfrom
feature/330-rtl-hebrew-support

Conversation

@julianhille

@julianhille julianhille commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

Draw right-to-left text such as Hebrew in visual order, on native and Wasm, through a new opt-in direction option on low-level writeText() and on Recipe text() / textDimensions().

PDF draws the glyphs of a text run from left to right in the order they are written, while Hebrew is typed in logical order. With direction set, text is reordered with the Unicode Bidirectional Algorithm (bidi-js 1.1.0, MIT) before it is drawn:

  • Hebrew runs are reversed, while numbers and Latin words inside them keep their order.
  • Brackets are mirrored.
  • Points (niqqud) and other combining marks stay on their letter and are drawn before it, where Hebrew fonts expect them.
  • Invisible direction marks steer the order and are neither drawn nor measured.
direction Paragraph direction
"none" (default) No reordering; text is drawn exactly as given, as before
"auto" Taken from the paragraph's first strong letter
"rtl" / "ltr" Set explicitly

The values are exported as TextDirection and Recipe.TextDirection on both ends. Any other value throws a TypeError before anything is drawn.

Recipe

  • Text is wrapped in logical order, and each line is reordered on its own with its paragraph's direction. An HTML paragraph resolves its direction once across all of its runs.
  • A run can ask for a direction of its own. It is placed in the line as an isolate; a "none" run is kept exactly as given.
  • A line that reorders is drawn piece by piece in visual order and aligned by the width of what is drawn: plain text, HTML lines of several styled runs, and flowed runs.
  • A flowed run continues the direction of the paragraph the runs before it left open. Lines that wait for a later letter take its direction once it comes.
  • Justified right-to-left lines place their words from right to left, and the last line of a right-to-left paragraph ends at the right edge. A list indent stays at the line's start, its right edge.
  • With wrap: "clip", an overflowing right-to-left line keeps its start and loses its end on the left.
  • Hilite, links, underline and text-markup annotations follow the drawn pieces. They end where the same text drawn as typed ends them.
  • Character spacing goes between characters, never between a point and its letter. Measuring and drawing count the same gaps.
  • A no-break space ending a right-to-left line is drawn on its left, as the bidirectional algorithm places it.

Loading bidi-js

  • Native: bidi-js 1.1.0 is a pinned dependency of native-core.
  • Wasm: bidi-js 1.1.0 is a pinned dependency that createMuhammaraWasm() and createRecipe() load with a dynamic import("bidi-js"), so bundlers keep it as a chunk; a page without a bundler maps it in an import map.
    • bidi: false skips loading it. The new loadBidi(source?) loads it later, from that import or from a module the caller passes.
    • It is loaded once per page, Worker or process and shared by every instance.
    • Without it, every call that has to order text by direction throws an error that names bidi-js, instead of drawing the text in the wrong order. Text drawn as given, with "none", needs no bidi-js.

Fixed on the way

Default "none" output changes only where these fixes apply; each is in the changelogs.

Native:

  • A flowed line ends at every paragraph separator that ends a run, including U+0085, which a plain line also no longer ends with as a missing glyph.
  • A flowed line keeps the spaces it starts with when movedown() or a line break ends it.
  • The space inside a flowed HTML run's first element is kept there.
  • U+2007 and U+202F are kept at a line's end, as U+00A0 was.
  • A long text flow is laid out in time that grows with its runs, and a font's line height is measured once per size. A flow of 8000 runs took 15 s on develop and now takes 1.5 s.

Wasm:

  • Non-breaking spaces (U+00A0, U+2007, U+202F) stay inside their word and on their line.
  • Right-aligned lines end at the right content edge.
  • Plain and flowed text starts a new line at every mandatory line break.
  • Text written after a flow that ends with a line break is drawn below it.
  • Each text is measured once per font and size. A flow of 3000 different words took 8.6 s and now takes 0.8 s.

Limits (documented)

  • Arabic and other joining scripts are reordered but not shaped. Only Hebrew is supported.
  • Points are placed before their letter, without shaping rules.
  • The PDF stores text in visual order, so text extraction and replaceText() see the visual order.
  • Low-level writeText() callers measure the text as typed, which can miss a margin by a fraction of a point. Recipe's textAlign: "right" measures it as drawn.

Test matrix

test-matrices/direction-matrix/ runs the same cases on native and Wasm: 553 variations from one YAML file per scenario. Each file holds its input, the expected output of both ends (wasm: same where they match) and, where the ends differ for an older reason, a knownDifference with that reason.

  • Kinds:
    • single: one text() in every direction, spacing and alignment.
    • fit: a box only just wide enough for its text.
    • break: every paragraph separator, ending a text and a flowed run.
    • flow: flows of plain, HTML and empty runs.
    • markup: links and markup, as typed and in each direction.
  • Each case checks its properties (right edge, inside the box, one line, cursor below a flow, annotations as typed), its own end's output, and parity with the other end.
  • UPDATE_DIRECTION_MATRIX=1 rewrites an end's expected output. The README describes the format.

Parity checklist

  • Implementation: native and Wasm. The bidi helpers and the shared line-break and no-break-space patterns live in lib/text-direction.js, identical on both ends apart from module syntax. Native wraps the addon's content-context writeText using develop's Symbol.for pattern.
  • Layers: low-level writeText reorders one line; Recipe adds wrapping, alignment, flows and markup.
  • Tests: TextDirectionTest and recipe/text-direction on both ends, the shared direction matrix, and a native docs example test. Wasm also tests running without bidi-js and loadBidi() in fresh processes.
  • Types: TextDirection, direction on writeText, text() and textDimensions(), Wasm's bidi option and loadBidi(), with strict type tests on native, native-with-source and Wasm.
  • Docs: how-to "Write Right-to-Left Text" in both doc sets, the low-level text pages, Wasm browser setup (loading bidi-js) and DIFFERENCES.md.
  • Browser example: a Wasm "rtl-text" tab, which creates its Recipe with bidi: false and loads bidi-js with loadBidi().
  • Changelog: Added and Fixed in both changelogs.
  • CI: both workflows also run when test-matrices/** changes.

Checks

  • npm test: 1586 passing; npm run test:docs: 26 passing
  • npm run wasm:test: 1499 passing; Wasm browser tests in Chrome
  • native, native-with-source and Wasm strict type tests
  • npm run test:codestyle
  • wasm:test:paths, wasm:test:exports

Changelog

CHANGELOG.md (native)

Added

  • Draw right-to-left text such as Hebrew in visual order when writeText() or Recipe text() is given the new direction option (TextDirection / Recipe.TextDirection). "auto" takes each paragraph's direction from its first strong letter, and "ltr" and "rtl" set it. Text is reordered with the Unicode Bidirectional Algorithm (bidi-js, MIT, a new dependency of @muhammara/native-core): numbers and Latin words inside Hebrew keep their order, brackets are mirrored, points are drawn on their letters, and invisible direction marks are neither drawn nor measured. Recipe reorders each wrapped line with the direction and levels of its whole paragraph, also across the styled runs of an HTML line, places justified words from right to left, and ends the last line of a justified right-to-left paragraph at the right edge; textDimensions() takes the same option, and table() takes it for the table and each column, whose headers take it too. The default, "none", draws text exactly as given, as before; see Write Right-to-Left Text #330

Fixed

  • End a flowed Recipe line at every paragraph separator that ends a run: \r, vertical tab, form feed, U+0085, U+2028 and U+2029 no longer draw as missing glyphs with the next run on the same line, as \n already did, text written after a flow that ends with one starts below the flow instead of over its last line, and a plain line ending in U+0085 no longer ends a missing glyph short of the right edge #330
  • Keep the spaces a Recipe line starts with when a line break ends it, in a text box or a flow, or movedown() ends a flowed line, as Wasm does; they were dropped only on such lines, and so was the space a later run on such a line starts with, so styled or flowed runs touched #330
  • Keep the space that the first element of a flowed HTML run starts with, as in <span> 6 7</span>, inside that element, as Wasm does; it was dropped, so the run touched the text before it #330
  • Keep a figure space (U+2007) or a narrow no-break space (U+202F) at the start and end of a Recipe line, as the no-break space (U+00A0) already was and as Wasm does #330
  • Write no link annotation without width for Recipe text, such as for a run of only spaces at the end of a line or a justified HTML line whose link ended before it started, as Wasm does #330
  • Lay out a long Recipe text flow in time that grows with its runs: each flowed text() call copied and searched every run the flow had, so a flow built word by word took time with the square of its words #330
  • Measure the line height of a Recipe font once for each size instead of for every text() call, which took about a millisecond each #330

packages/wasm/CHANGELOG.md (Wasm)

Added

  • Draw right-to-left text such as Hebrew in visual order when writeText() or Recipe text() is given the new direction option (TextDirection / Recipe.TextDirection). "auto" takes each paragraph's direction from its first strong letter, and "ltr" and "rtl" set it. Text is reordered with the Unicode Bidirectional Algorithm (bidi-js, MIT, a new dependency that createMuhammaraWasm() and createRecipe() load with import("bidi-js") unless given bidi: false, and the new loadBidi() loads later; a page without a bundler maps it in an import map): numbers and Latin words inside Hebrew keep their order, brackets are mirrored, points are drawn on their letters, and invisible direction marks are neither drawn nor measured. Recipe reorders each wrapped line with the direction and levels of its whole paragraph, also across the styled runs of an HTML line, places justified words from right to left, and ends the last line of a justified right-to-left paragraph at the right edge; textDimensions() takes the same option, and table() takes it for the table and each column, whose headers take it too. The default, "none", draws text exactly as given, as before; see Write Right-to-Left Text #330

Fixed

  • Keep a word joined by a non-breaking space (U+00A0) together on a justified Recipe line instead of widening the gap inside it, as native does #330
  • Keep words joined by a figure space (U+2007) or a narrow no-break space (U+202F) on one Recipe line, as native does and as U+00A0 already was; Wasm wrapped lines at them #330
  • End right-aligned Recipe text where its glyphs end, at the right edge of a text box's content, as native does. It ended the box's left padding plus the first glyph's side bearing past that edge. Centered text, table headers included, was off by half that bearing, and the text-markup annotations of HTML lines ended that bearing short of their glyphs #330
  • Start a new line of Recipe text, plain, HTML or flowed, at every mandatory line break, as native does: \r\n, \r, vertical tab, form feed, U+0085, U+2028 and U+2029 no longer draw as missing glyphs on the same line #330
  • Keep the blank line between two line breaks in plain Recipe text, such as "a\n\nb" or "a\r\n\r\nb", as native does; it was dropped #330
  • Draw text written after a flow that ends with a paragraph separator other than \n, or with a line break followed by a run of spaces, below the flow instead of over its last line #330
  • Measure each text once for each font and size in a Recipe: every measurement read its glyphs from the font file, about a millisecond each, and Recipe measured each line's height and each flowed word again and again, so a flow of 3000 different words took 8.6 seconds instead of 0.8 #330

Closes #330

@read-the-docs-community

read-the-docs-community Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

@read-the-docs-community

read-the-docs-community Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

@julianhille
julianhille force-pushed the feature/330-rtl-hebrew-support branch 8 times, most recently from 18536d1 to 37c2bd3 Compare October 6, 2026 14:53
@julianhille
julianhille force-pushed the feature/330-rtl-hebrew-support branch from 88825f7 to 63aa835 Compare October 11, 2026 20:49
Hebrew passed to writeText() or Recipe text() was drawn reversed,
because PDF draws the glyphs of a text run left to right in the order
they are written (#330).

A new opt-in direction option, exposed as TextDirection and
Recipe.TextDirection on native and Wasm, reorders text with the Unicode
Bidirectional Algorithm (bidi-js 1.1.0, MIT) before it is drawn:

- "none" (default): text is drawn exactly as given, as before
- "auto": each paragraph takes the direction of its first strong letter
- "ltr" / "rtl": set the paragraph direction

Right-to-left runs are reversed while numbers and Latin words keep their
order, brackets are mirrored, points stay on their letters and take no
character spacing, and bidi formatting characters are neither drawn nor
measured.

Recipe wraps lines in logical order and reorders each line with its
paragraph's direction. A line that reorders is drawn piece by piece in
visual order and aligned by what it draws, also across the styled runs
of HTML and flowed text. A flowed run keeps the direction its call asks
for, placed in the line as an isolate, and continues the paragraph the
runs before it left open; lines that wait for a later letter take its
direction once it comes. Justified lines place their words from right
to left, a list indent stays at the start of the line, an overflowing
clipped line keeps its start, and hilite, links, underline and
text-markup annotations follow the drawn pieces. textDimensions() and
table() take the same option; a table header takes the direction of its
column or table.

The helpers live in lib/text-direction.js, identical on both ends and
covered by the shared-module parity tests. Native wraps the addon's
content-context writeText, which the addon now exports for that. bidi-js
is a dependency of native-core and of the Wasm package; Wasm loads it
with a dynamic import, skips it with the new bidi: false factory option,
loads it later with loadBidi(), and throws an error naming bidi-js before
drawing text that needs it.

Bringing the two Recipes in line also fixes text drawn as given: flowed
lines end at every paragraph separator, keep the spaces they start with,
and leave the cursor below the flow; no-break spaces stay in their word;
Wasm right-aligned lines end at the content edge; and long flows are laid
out in time that grows with their runs.
The new Right to left tab draws Hebrew with the direction option: numbers
and Latin words inside it, points, an HTML paragraph and a justified box,
with an uploaded font that has Hebrew glyphs. Its Recipe is created with
bidi: false and loads bidi-js with loadBidi(), so it runs on the page and
in a Worker; the page maps bidi-js in an import map, and Pages staging
serves its own copy. The browser test uploads the Arial fixture and runs
it both ways, and the Node test checks that the example finds the
installed bidi-js.
The staged example serves its own copy of bidi-js, which the stage script
resolves from node_modules, but neither Pages deploy job installed the
dependencies, so staging failed with "Cannot find module
'bidi-js/package.json'".
A shared matrix in test-matrices/direction-matrix draws every case on
native and on Wasm: Hebrew with points, punctuation, digits and quotes,
Latin with accents and the no-break spaces, in every direction, alignment
and character spacing, boxes only just wide enough for their text, every
paragraph separator ending a text or a flowed run, flows of plain, HTML
and empty runs, and links and markup. Each scenario is one YAML file with
its input and the expected output of both ends; a case the ends must draw
alike fails once they differ, and the differences that also exist without
right-to-left text are listed with their reason. Each case also checks its
properties, such as a right-aligned line ending at the edge and text after
a flow starting below it. Both CI workflows run when the matrix changes.
On Node 20 Array.from(segmenter.segment(text)) takes memory quadratic in
the length of the text: 200,000 characters ran out of memory, which failed
the native tests on Windows, macOS and Linux. Segment the text in chunks
that end where their last segment starts, and never between the halves of
a surrogate pair, so the clusters are the same as segmenting it in one go.
Two Wasm text direction tests drew a very long line within a fixed time,
5 and 15 seconds. They take about 1.5 and 3 seconds, but the sanitizer
build is several times slower, so both timed out in CI while their time
grew linearly. Draw the line at one quarter of its length as well, and
require four times the length to take less than ten times as long: about
four times for linear time and sixteen times for quadratic time.
A clipped line that reorders kept the rest of its paragraph instead of
dropping it after the first word that does not fit, so reordering could
draw those words inside the box. That is an exception no one asked for and
a branch on each end: clipped text keeps the first word that does not fit,
cut at the box edge, and drops the rest, whatever its direction, as the
how-to already says: the line keeps its start and loses its end.
@julianhille
julianhille force-pushed the feature/330-rtl-hebrew-support branch from 47b0e26 to f0eb60f Compare October 14, 2026 12:56
…imit

The native test that draws 6000 pointed letters within 15 seconds timed out
on the slow macOS x64 and riscv64 runners, as its Wasm twin did in the
sanitizer build, while its time grew linearly. Draw the line at one quarter
of its length as well and require four times the length to take less than
ten times as long.
The test drew 50 and 200 thousand spaces and timed the whole page, reading
its text back included. The sanitizer build takes about 0.4 ms for each
character, so it did not finish within 120 seconds. Draw 4 and 16 thousand,
time the drawing only, and give the sibling test, which takes 86 seconds
there, more room.
The suite set its own 60 second timeout, which overrode the 120 seconds the
emulated RISC-V job passes on the command line, so three of its tests timed
out there. Keep the longer timeout when the command line gives one, and
raise the job's to 300 seconds. Other runs keep 60 seconds.
@julianhille
julianhille merged commit 68e8e35 into develop Oct 14, 2026
49 checks passed
@julianhille
julianhille deleted the feature/330-rtl-hebrew-support branch October 14, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RTL Support (Arabic, Hebrew...)

1 participant