diff --git a/CHANGELOG.md b/CHANGELOG.md index e78deae..503dce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,7 @@ **Page & layout** - Page margins (cm) and page orientation (portrait / landscape) - Page format picker (Layout panel): 15 Word/LibreOffice sizes — A3–A6, ISO B4–B6, JIS B4/B5, Letter, Legal, Tabloid, Executive, Folio, Statement — each showing its cm dimensions; drives the on-screen page, pagination, and ODT/DOCX/PDF export, and is detected & adopted on import +- Right-to-left pages (ODF `style:writing-mode="rl-tb"`, Word's `w:bidi`): the body's base direction, so a multi-column page fills its columns **from the right** as LibreOffice does, and bidi resolves a Hebrew or Arabic line the way the file means it. Round-trips through both formats - Mirrored page margins (ODF `style:page-usage="mirrored"`, Word's `w:mirrorMargins`): the declared left/right are the inner/outer pair, and an even (left-hand) page swaps them — body text and the header/footer band alike. Round-trips through both formats - Multi-column (newspaper) layout: 1–3 columns with adjustable gap, applied to the whole document (no selection) or to selected paragraphs, Word-style — text fills column 1 to the page bottom, then column 2, and flows across pages with mid-paragraph line breaks; a section followed by other content balances its columns. Round-trips to ODF `text:section`/`style:columns` and DOCX continuous sections with `w:cols` - Page margins per section: a section's own `w:pgMar` / ODF page layout is read, rendered and written back, and an ODF page style that hands over to another (`style:next-style-name`, the title-page idiom) gives its layout to the section's first page and the successor's to the rest. The ruler and the header/footer layer still show the document's own pair @@ -136,7 +137,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Save as template (`.ott` / `.dotx`), multi-document management, recent-files list - Watermark; page background and page border - Thesaurus, grammar check, word completion, AutoText / building blocks -- Right-to-left and vertical text, Asian typography (ruby) +- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby). Right-to-left **pages** are supported (see above); a per-*paragraph* direction is not — the page's own carries the whole body - Password-protected ODT/DOCX; digital signatures - Navigator / outline view, split view - PWA / offline support diff --git a/src/App.svelte b/src/App.svelte index 4766061..172e474 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -26,6 +26,7 @@ import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation'; import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval'; import { loadSpacingModel, saveSpacingModel, type SpacingModel } from './lib/storage/spacingModel'; + import { loadPageRtl, savePageRtl } from './lib/storage/writingMode'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte'; import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet'; @@ -128,6 +129,8 @@ let pageFormat: PageFormat = $state(loadPageFormat()); let tabIntervalCm = $state(loadTabInterval()); let spacingModel: SpacingModel = $state(loadSpacingModel()); + // The page's text direction, from the file's own page setup (writingMode.ts). + let pageRtl = $state(loadPageRtl()); // Bumped for each document opened (import, new); Editor hides the page until that // document's pagination holds still. let documentEpoch = $state(0); @@ -196,6 +199,10 @@ saveSpacingModel(spacingModel); }); + $effect(() => { + savePageRtl(pageRtl); + }); + $effect(() => { saveDocumentLanguage(documentLanguage); void spellController.setLanguage(documentLanguage); @@ -526,6 +533,7 @@ if (result.format) pageFormat = result.format; if (result.tabIntervalCm) tabIntervalCm = result.tabIntervalCm; spacingModel = result.spacingModel; + pageRtl = result.rtl; // Adopt the document's spell-check language (the $effect switches the // controller + loads its dictionary). null = file declared none; keep ours. if (result.language) documentLanguage = result.language; @@ -604,7 +612,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -620,7 +628,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -638,7 +646,7 @@ try { const json = editor.getJSON() as Parameters[0]; const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -1104,6 +1112,7 @@ /> -
+
{#if gapStripeStyles.length}
{#each bandStyles as b} diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index c879872..39e5be3 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -24,6 +24,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`. - **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real ``/``/`` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'pt solid #RRGGBB'`) overriding the table-level default border per side. - **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `` + preset `` (roundRect/ellipse, static LibreOffice-style paths) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text). - **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `` (or `` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`. +- **Right-to-left pages**: ODF gets `style:writing-mode="rl-tb"` on the page layout (`rewriteStylesXml`), DOCX a `` in every `w:sectPr` from a post-pack pass (`applyBidiDocx`) — the package exposes only the paragraph-level flag. - **Mirror margins** (`applyMirrorMarginsDocx`, `export/docx.ts`): the `docx` package writes only the per-section `w:pgMar`, so Word's document-level `w:mirrorMargins` goes into `word/settings.xml` in the same post-pack pass; ODF's flag rides `rewriteStylesXml`. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel. - **Bookmarks & cross-references** (`replaceBookmarks`/`applyBookmarks`): the `BMS`/`BME`/`XRF` sentinels are spliced into the **run text** rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to ``/``/``. DOCX needs no pass at all — the `docx` package has `Bookmark`, `SimpleField` (`REF`/`PAGEREF`) and `InternalHyperlink`. See `docs/architecture/formatting.md`. - **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles. diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 4964212..8febb68 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -745,6 +745,20 @@ function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array { return zipSync(out); } +// Post-pack pass: a right-to-left section is in every w:sectPr, which the +// docx package does not expose (it has the paragraph-level flag only). +function applyBidiDocx(bytes: Uint8Array): Uint8Array { + const files = unzipSync(bytes); + const docBytes = files['word/document.xml']; + if (!docBytes) return bytes; + files['word/document.xml'] = strToU8( + strFromU8(docBytes).replace(/]*>/g, (m) => `${m}`), + ); + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + // ---- paragraphs ------------------------------------------------------------ function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined { switch (attrs?.textAlign) { @@ -1229,6 +1243,7 @@ export async function buildDocx( pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, + rtl = false, ): Promise { docLangTag = localeTag(language ? language.language : 'en'); exportSheet = styles; @@ -1327,5 +1342,6 @@ export async function buildDocx( const blob = await Packer.toBlob(doc); const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas); - return margins.mirrored ? applyMirrorMarginsDocx(packed) : packed; + const mirrored = margins.mirrored ? applyMirrorMarginsDocx(packed) : packed; + return rtl ? applyBidiDocx(mirrored) : mirrored; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index aeefc24..5a67125 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -2142,7 +2142,7 @@ function rezipOdt(files: Record): Uint8Array { // Rewrite styles.xml to match the editor's preview: default font Liberation Serif → // Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes & // margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES). -function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set, usedTables: Set = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false): Uint8Array { +function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set, usedTables: Set = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false): Uint8Array { const files = unzipSync(odtBytes); const stylesBytes = files['styles.xml']; if (!stylesBytes) return odtBytes; @@ -2163,6 +2163,11 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr styles = styles.replace(/ { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false): Promise { // Images become IMG sentinels before serialization; applyImages resolves them and writes // the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks // (so PGB misses their blocks) and before the inline passes (which then cover them). @@ -3577,7 +3582,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Effects first: applyCharacterStyles then clones the style that already carries them. const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes))); const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name)); - const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true); + const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl); const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); // Sections past the first get their own master page, which is where ODF keeps a // section's header/footer; the SEC-marked block points at it. diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index c57ac6a..1de246a 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -37,7 +37,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Header/footer import -- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` on the page layout marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). The zone's gap to the body is laid out *inside* the band, not added to it, and `style:dynamic-spacing` changes neither side. The band is the declared height only as a **floor**: LibreOffice grows it to hold the zone's own lines, and a footer's first paragraph `fo:margin-top` rides the band with them — a header's `fo:margin-bottom` does not, it just hangs into the body (all probed). That growth is the runtime reach (`Editor.svelte`'s `hfReachPx`, which takes the biggest run's size as the line height and the zone paragraph's `spaceBefore`), so a page whose zone is empty keeps the floor. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. +- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` on the page layout marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The zone's gap to the body is laid out *inside* the band, not added to it, and `style:dynamic-spacing` changes neither side. The band is the declared height only as a **floor**: LibreOffice grows it to hold the zone's own lines, and a footer's first paragraph `fo:margin-top` rides the band with them — a header's `fo:margin-bottom` does not, it just hangs into the body (all probed). That growth is the runtime reach (`Editor.svelte`'s `hfReachPx`, which takes the biggest run's size as the line height and the zone paragraph's `spaceBefore`), so a page whose zone is empty keeps the floor. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. ## Defaults on DOCX import diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 0e96149..276d0e3 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -186,6 +186,8 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = content: { type: 'doc', content: blocks }, styles: collectStyleSheet(ctx), margins: withMirror(first.margins ?? sect.margins, mirrored), + // A right-to-left section (w:bidi): the columns fill from the right. + rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(), orientation: sect.orientation, format: sect.format, tabIntervalCm: docTabInterval(files), diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 01fbe50..5cfc62a 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -45,6 +45,9 @@ export interface OdtImportResult { margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null; + // A right-to-left page (ODF style:writing-mode="rl-tb", Word w:bidi): the columns + // fill from the right and the body's base direction is RTL. + rtl: boolean; // The grid every tab past the last custom stop falls on; the format's own fallback // when the file declares none. tabIntervalCm: number; @@ -649,6 +652,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = margins: geometry?.margins ?? null, orientation: geometry?.orientation ?? null, format: geometry?.format ?? null, + rtl: geometry?.rtl ?? false, tabIntervalCm: resolver.defaultTabInterval(), spacingModel: odfSpacingModel(files), header, diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 8695441..5f65e6f 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -617,7 +617,7 @@ export class StyleResolver { // Page margins + orientation + format from the master page's layout. With a // header/footer the body margin is page margin + zone height + spacing (inverse of // the export mapping). Format is matched from fo:page-width/height (fallback A4). - pageGeometry(pageName: string | null = null): { margins: PageMargins; orientation: Orientation; format: PageFormat } | null { + pageGeometry(pageName: string | null = null): { margins: PageMargins; orientation: Orientation; format: PageFormat; rtl: boolean } | null { const props = this.pageLayoutEl(pageName)?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; if (!props) return null; @@ -642,7 +642,10 @@ export class StyleResolver { const h = lengthToCm(props.getAttributeNS(NS.fo, 'page-height')); const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait'; const format: PageFormat = w != null && h != null ? (formatFromCm(w, h) ?? 'A4') : 'A4'; - return { margins, orientation, format }; + // style:writing-mode: rl-tb is a right-to-left page — the columns fill from the + // right and the text's base direction is RTL. The vertical modes are not read. + const rtl = (props.getAttributeNS(NS.style, 'writing-mode') ?? '').startsWith('rl'); + return { margins, orientation, format, rtl }; } // Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 9cf26bb..8a5937c 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -10,6 +10,7 @@ (`AddParaTableSpacing`), always `'max'` on DOCX import, and written back on ODF export. `editor.css` turns `--space-before` into padding or margin accordingly (CSS margins can only collapse, so `'add'` cannot be a margin). +- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `odf-editor-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read. - **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap). Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview. diff --git a/src/lib/storage/writingMode.ts b/src/lib/storage/writingMode.ts new file mode 100644 index 0000000..5b56849 --- /dev/null +++ b/src/lib/storage/writingMode.ts @@ -0,0 +1,13 @@ +// The page's text direction: ODF style:writing-mode="rl-tb" / Word's w:bidi. A +// right-to-left page fills its columns from the right and sets the body's base +// direction, so bidi resolves a Hebrew or Arabic line the way the file means it. + +const KEY = 'odf-editor-page-rtl'; + +export function loadPageRtl(): boolean { + return localStorage.getItem(KEY) === 'true'; +} + +export function savePageRtl(rtl: boolean): void { + localStorage.setItem(KEY, String(rtl)); +} diff --git a/tests/unit/writing-mode.test.ts b/tests/unit/writing-mode.test.ts new file mode 100644 index 0000000..6d63d9b --- /dev/null +++ b/tests/unit/writing-mode.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; +import { importDocx } from '../../src/lib/import/docx'; +import { buildOdt } from '../../src/lib/export/odt'; +import { buildDocx } from '../../src/lib/export/docx'; + +// ODF style:writing-mode="rl-tb" / Word w:bidi: a right-to-left page, whose columns +// fill from the right. The vertical modes (tb-rl) are deliberately not read. +const NS = + 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' + + 'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' + + 'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' + + 'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'; + +function odt(mode: string): Uint8Array { + const styles = ` + + + + + + + + `; + const content = ` + שלום`; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const doc = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'שלום' }] }] }; +const margins = { top: 2, bottom: 2, left: 2, right: 2 }; + +describe('a right-to-left page', () => { + it('reads style:writing-mode from the page layout', () => { + expect(importOdt(odt('style:writing-mode="rl-tb"')).rtl).toBe(true); + expect(importOdt(odt('')).rtl).toBe(false); + expect(importOdt(odt('style:writing-mode="lr-tb"')).rtl).toBe(false); + }); + + it('round-trips through an ODF export', async () => { + const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', undefined, undefined, 'add', true); + expect(strFromU8(unzipSync(bytes)['styles.xml'])).toContain('style:writing-mode="rl-tb"'); + expect(importOdt(bytes).rtl).toBe(true); + }); + + it('leaves a left-to-right export alone', async () => { + const bytes = await buildOdt(doc, margins); + expect(strFromU8(unzipSync(bytes)['styles.xml'])).not.toContain('rl-tb'); + expect(importOdt(bytes).rtl).toBe(false); + }); + + it('round-trips through a DOCX export as w:bidi', async () => { + const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', undefined, undefined, true); + expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain(''); + expect(importDocx(bytes).rtl).toBe(true); + }); + + it('leaves a left-to-right DOCX export alone', async () => { + const bytes = await buildDocx(doc, margins); + expect(strFromU8(unzipSync(bytes)['word/document.xml'])).not.toContain(''); + expect(importDocx(bytes).rtl).toBe(false); + }); +});