Repository navigation
Draw right-to-left (Hebrew) text in visual order - #884
Merged
Merged
Conversation
Documentation build overview
19 files changed ·
|
Documentation build overview
20 files changed ·
|
julianhille
force-pushed
the
feature/330-rtl-hebrew-support
branch
8 times, most recently
from
October 6, 2026 14:53
18536d1 to
37c2bd3
Compare
julianhille
force-pushed
the
feature/330-rtl-hebrew-support
branch
from
October 11, 2026 20:49
88825f7 to
63aa835
Compare
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
force-pushed
the
feature/330-rtl-hebrew-support
branch
from
October 14, 2026 12:56
47b0e26 to
f0eb60f
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Draw right-to-left text such as Hebrew in visual order, on native and Wasm, through a new opt-in
directionoption on low-levelwriteText()and on Recipetext()/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
directionset, text is reordered with the Unicode Bidirectional Algorithm (bidi-js 1.1.0, MIT) before it is drawn:direction"none"(default)"auto""rtl"/"ltr"The values are exported as
TextDirectionandRecipe.TextDirectionon both ends. Any other value throws aTypeErrorbefore anything is drawn.Recipe
"none"run is kept exactly as given.wrap: "clip", an overflowing right-to-left line keeps its start and loses its end on the left.Loading bidi-js
bidi-js1.1.0 is a pinned dependency of native-core.bidi-js1.1.0 is a pinned dependency thatcreateMuhammaraWasm()andcreateRecipe()load with a dynamicimport("bidi-js"), so bundlers keep it as a chunk; a page without a bundler maps it in an import map.bidi: falseskips loading it. The newloadBidi(source?)loads it later, from that import or from a module the caller passes."none", needs no bidi-js.Fixed on the way
Default
"none"output changes only where these fixes apply; each is in the changelogs.Native:
movedown()or a line break ends it.Wasm:
Limits (documented)
replaceText()see the visual order.writeText()callers measure the text as typed, which can miss a margin by a fraction of a point. Recipe'stextAlign: "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: samewhere they match) and, where the ends differ for an older reason, aknownDifferencewith that reason.single: onetext()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.UPDATE_DIRECTION_MATRIX=1rewrites an end's expected output. The README describes the format.Parity checklist
lib/text-direction.js, identical on both ends apart from module syntax. Native wraps the addon's content-contextwriteTextusing develop'sSymbol.forpattern.writeTextreorders one line; Recipe adds wrapping, alignment, flows and markup.TextDirectionTestandrecipe/text-directionon both ends, the shared direction matrix, and a native docs example test. Wasm also tests running without bidi-js andloadBidi()in fresh processes.TextDirection,directiononwriteText,text()andtextDimensions(), Wasm'sbidioption andloadBidi(), with strict type tests on native, native-with-source and Wasm.DIFFERENCES.md.bidi: falseand loads bidi-js withloadBidi().AddedandFixedin both changelogs.test-matrices/**changes.Checks
npm test: 1586 passing;npm run test:docs: 26 passingnpm run wasm:test: 1499 passing; Wasm browser tests in Chromenpm run test:codestylewasm:test:paths,wasm:test:exportsChangelog
CHANGELOG.md(native)Added
writeText()or Recipetext()is given the newdirectionoption (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, andtable()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 #330Fixed
\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\nalready 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 #330movedown()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<span> 6 7</span>, inside that element, as Wasm does; it was dropped, so the run touched the text before it #330text()call copied and searched every run the flow had, so a flow built word by word took time with the square of its words #330text()call, which took about a millisecond each #330packages/wasm/CHANGELOG.md(Wasm)Added
writeText()or Recipetext()is given the newdirectionoption (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 thatcreateMuhammaraWasm()andcreateRecipe()load withimport("bidi-js")unless givenbidi: false, and the newloadBidi()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, andtable()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 #330Fixed
\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"a\n\nb"or"a\r\n\r\nb", as native does; it was dropped #330\n, or with a line break followed by a run of spaces, below the flow instead of over its last line #330Closes #330