From 55f27da855bb328dc1f7a940fd1c9dc425891523 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 06:04:12 +0200 Subject: [PATCH] feat: a single paragraph can run right to left The block's own direction, set in the Paragraph dialog where LibreOffice keeps it, so a quoted Hebrew or Arabic paragraph turns inside a left-to-right document. Rides ODF style:writing-mode / Word w:bidi through cells and list items as well as top-level blocks; the direction the page already has is inheritance, so neither importer stamps it on every block. --- CHANGELOG.md | 3 +- src/lib/components/ParagraphDialog.svelte | 11 +++++ src/lib/editor/extensions.ts | 3 ++ src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/textDirection.ts | 53 ++++++++++++++++++++++ src/lib/export/CLAUDE.md | 2 +- src/lib/export/docx.ts | 2 + src/lib/export/odt.ts | 24 ++++++++-- src/lib/i18n/locales/de.ts | 4 ++ src/lib/i18n/locales/en.ts | 4 ++ src/lib/import/docx.ts | 20 ++++++-- src/lib/import/docxStyles.ts | 12 +++++ src/lib/import/odt.ts | 10 +++- tests/roundtrip.test.ts | 36 +++++++++++++++ 14 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 src/lib/editor/extensions/textDirection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f103c2..4ca0203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ - 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 +- Per-paragraph text direction (Paragraph dialog ▸ Text direction, where LibreOffice keeps it): a single quoted Hebrew or Arabic paragraph turns right-to-left inside a left-to-right document — and back. Round-trips as the block's own `style:writing-mode` / `w:bidi`, in table cells and list items too; the direction the page already has is inheritance, not formatting, so neither importer stamps it on every block - 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 @@ -145,7 +146,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 -- 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 +- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby) - Password-protected ODT/DOCX; digital signatures - Navigator / outline view, split view - PWA / offline support diff --git a/src/lib/components/ParagraphDialog.svelte b/src/lib/components/ParagraphDialog.svelte index d5af9b9..bafc4fe 100644 --- a/src/lib/components/ParagraphDialog.svelte +++ b/src/lib/components/ParagraphDialog.svelte @@ -27,6 +27,8 @@ // An unset paragraph renders left, so that's what the box shows; a selection // mixing two alignments still reads '' and leaves it blank. let align = $derived(read('textAlign', 'left')); + // '' is the page's own direction (LibreOffice's "Use superordinate object settings"). + let dir = $derived(read('dir', null) ?? ''); let indent = $derived(read('indent', 0)); let indentRight = $derived(read('indentRight', 0)); let indentFirst = $derived(read('indentFirst', 0)); @@ -88,6 +90,15 @@ + + diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index c4a8ba4..31a1c49 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -32,6 +32,7 @@ import { BlockFontSize } from './extensions/blockFontSize'; import { ParagraphStyle } from './extensions/paragraphStyle'; import { CharacterStyle } from './extensions/characterStyle'; import { PageBreak } from './extensions/pageBreak'; +import { TextDirection } from './extensions/textDirection'; import { Indent } from './extensions/indent'; import { TabStops } from './extensions/tabStops'; import { FormattingMarks } from './extensions/formattingMarks'; @@ -139,6 +140,8 @@ export const extensions = [ // Named character style on a run (LibreOffice style:family="text" / Word w:rStyle). CharacterStyle, PageBreak, + // Per-block base direction, overriding the page's (storage/writingMode.ts). + TextDirection, Indent, TabStops, Heading.configure({ levels: HEADING_LEVELS as Level[] }), diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 504b867..5a465f7 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -25,6 +25,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`paragraphSpacing.ts`** (`ParagraphSpacing`) — `spaceBefore`/`spaceAfter` in **pt**, round-tripping 1:1 to `fo:margin-top`/`fo:margin-bottom`. Space after is a margin, space before rides `--space-before` (`storage/spacingModel.ts` decides padding vs margin); pageBreaks.ts drops it where a page break put the block at a page top, as LibreOffice does. - **`blockFontSize.ts`** — the font of the paragraph mark (Word `w:pPr/w:rPr`, ODF the paragraph's own text properties). - **`pageBreak.ts`** — the text-flow attrs of a paragraph/heading: `breakBefore: 'page'`, plus `keepNext`/`keepLines` (see `docs/architecture/pagination.md`). Owns `Mod-Enter`. +- **`textDirection.ts`** — a block's own base direction (`dir` attr; ODF `style:writing-mode`, Word `w:bidi`), overriding the page's (`storage/writingMode.ts`). The HTML `dir` is the whole rendering — it resolves the bidi run order and `text-align: start` follows it, so an unaligned block flips. Both importers suppress the direction the page already has: it is inheritance, not formatting. Set in `ParagraphDialog.svelte`, where LibreOffice keeps it. - **`formattingMarks.ts`** (`FormattingMarks`) — decorations marking spaces (`·`) and tabs (`→`) when `.paper.show-formatting-marks` is set. - **`bulletList.ts`** / **`orderedList.ts`** — the TipTap lists plus a marker-type attr: `setBulletChar` (innermost list, `null` = default cycle) and the ordered numbering cycle (`utils/orderedListTypes.ts`, multilevel targets the outermost list). - **`link.ts`** — hyperlink mark; owns `Mod-k`, which fires the event `Toolbar.svelte` listens for to open the link dialog. A `#name` href targets a bookmark: `Editor.svelte`'s `handleClick` scrolls to it instead of opening a tab. The `plain` attr (`data-plain`, `editor.css`) suppresses the editor's own blue: Word paints a link through its **Hyperlink character style**, which arrives as an ordinary mark, so every DOCX link is plain and one carrying no style is drawn like the text around it — while LibreOffice paints every ODF `text:a` styled or not (probed), so those never are. The DOCX exporter paints `#0563C1` only on a non-plain link, which is how a round trip tells its own links from a file's. diff --git a/src/lib/editor/extensions/textDirection.ts b/src/lib/editor/extensions/textDirection.ts new file mode 100644 index 0000000..6425a3b --- /dev/null +++ b/src/lib/editor/extensions/textDirection.ts @@ -0,0 +1,53 @@ +import { Extension } from '@tiptap/core'; + +// A block's own base direction (ODF style:writing-mode, Word w:bidi), overriding the +// page's (storage/writingMode.ts) — what a quoted Hebrew or Arabic paragraph in a +// left-to-right document needs. `dir` on the element is the whole rendering: it resolves +// the bidi run order and `text-align: start` follows it, so an unaligned block flips. + +declare module '@tiptap/core' { + interface Commands { + textDirection: { + /** 'ltr'/'rtl' pin the direction; null falls back to the page's. */ + setTextDirection: (dir: 'ltr' | 'rtl' | null) => ReturnType; + }; + } +} + +export const TextDirection = Extension.create({ + name: 'textDirection', + + addOptions() { + return { + types: ['paragraph', 'heading'] as string[], + }; + }, + + addGlobalAttributes() { + return [ + { + types: this.options.types, + attributes: { + dir: { + default: null, + parseHTML: (element: HTMLElement) => { + const d = element.getAttribute('dir'); + return d === 'rtl' || d === 'ltr' ? d : null; + }, + renderHTML: (attributes: Record) => + attributes.dir === 'rtl' || attributes.dir === 'ltr' ? { dir: String(attributes.dir) } : {}, + }, + }, + }, + ]; + }, + + addCommands() { + return { + setTextDirection: (dir) => ({ commands }) => + this.options.types + .map((type) => commands.updateAttributes(type, { dir })) + .some((r) => r), + }; + }, +}); diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 8d04a83..1cc88de 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -24,7 +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. +- **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. A **paragraph's** own direction (`textDirection.ts`) rides that same flag in DOCX and the `ParaStyle`/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks. - **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 ea028e4..40f0397 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -953,6 +953,8 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { widowControl: attrs.widowControl === false ? false : undefined, keepNext: attrs.keepNext === true || undefined, keepLines: attrs.keepLines === true || undefined, + // w:bidi — the block's own base direction (textDirection.ts). + bidirectional: attrs.dir === 'rtl' ? true : attrs.dir === 'ltr' ? false : undefined, tabStops: stops.length ? stops.map((s) => ({ type: DOCX_TAB_TYPE[s.align], position: cmToTwip(s.pos), leader: DOCX_LEADER[s.leader ?? ''] })) : undefined, diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index cd69962..769dc68 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -287,6 +287,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (attrs.widowControl === false) return true; if (attrs.keepNext === true) return true; if (attrs.keepLines === true) return true; + if (attrs.dir === 'rtl' || attrs.dir === 'ltr') return true; for (const s of ['borderTop', 'borderRight', 'borderBottom', 'borderLeft']) if (typeof attrs[s] === 'string' && attrs[s] && attrs[s] !== 'none') return true; const ta = attrs.textAlign; @@ -869,12 +870,19 @@ type ParaStyle = { borderRight: string | null; borderBottom: string | null; borderLeft: string | null; + // The block's own base direction (textDirection.ts); null inherits the page's. + dir: 'ltr' | 'rtl' | null; }; function paraStyleIsEmpty(s: ParaStyle): boolean { return s.align === null && s.spaceBefore === null && s.spaceAfter === null && s.lineHeight === null && s.background === null && s.borderTop === null && s.borderRight === null - && s.borderBottom === null && s.borderLeft === null; + && s.borderBottom === null && s.borderLeft === null && s.dir === null; +} + +// ODF writes a direction as a writing mode; the vertical ones are not ours to emit. +export function writingModeOf(dir: 'ltr' | 'rtl' | null | undefined): string | null { + return dir === 'rtl' ? 'rl-tb' : dir === 'ltr' ? 'lr-tb' : null; } // Extract the overridable paragraph properties from a node's attrs. Left @@ -900,6 +908,7 @@ function paraStyleFromAttrs(attrs: TiptapNode['attrs']): ParaStyle { borderRight: border(attrs?.borderRight), borderBottom: border(attrs?.borderBottom), borderLeft: border(attrs?.borderLeft), + dir: attrs?.dir === 'rtl' || attrs?.dir === 'ltr' ? attrs.dir : null, }; } @@ -918,6 +927,8 @@ function paraStyleProps(style: ParaStyle): string[] { const v = style[attr]; if (v) props.push(`fo:border-${side}="${v}"`); } + const wm = writingModeOf(style.dir); + if (wm) props.push(`style:writing-mode="${wm}"`); return props; } @@ -1358,7 +1369,8 @@ function applyEmptyLineFontSizes(odtBytes: Uint8Array): Uint8Array { // A top-level paragraph's box spec for the PBX sentinel: bg|borderTop|Right|Bottom|Left, // each the raw value (canonical border 'pt solid #RRGGBB' is a valid fo:border) or '', -// then a widow flag, the right indent and a keep-with-next flag. '' when it needs none. +// then a widow flag, the right indent, a keep-with-next flag and the writing mode. +// '' when it needs none. function paraBoxSpec(attrs: TiptapNode['attrs']): string { const s = paraStyleFromAttrs(attrs); const noWidow = attrs?.widowControl === false; @@ -1366,14 +1378,15 @@ function paraBoxSpec(attrs: TiptapNode['attrs']): string { const keepLines = attrs?.keepLines === true; // odf-kit has a paragraph option for the left indent but none for the right one. const right = typeof attrs?.indentRight === 'number' && attrs.indentRight > 0 ? attrs.indentRight : 0; - if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext && !keepLines) return ''; + const wm = writingModeOf(s.dir); + if (!s.background && !s.borderTop && !s.borderRight && !s.borderBottom && !s.borderLeft && !noWidow && !right && !keepNext && !keepLines && !wm) return ''; return [s.background, s.borderTop, s.borderRight, s.borderBottom, s.borderLeft] .map((v) => v ?? '') - .concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '', keepLines ? 'g1' : '').join('|'); + .concat(noWidow ? 'w0' : '', right ? `${right}cm` : '', keepNext ? 'k1' : '', keepLines ? 'g1' : '', wm ?? '').join('|'); } function boxSpecToProps(spec: string): string { - const [bg, bt, br, bb, bl, widow, marginRight, keepNext, keepLines] = spec.split('|'); + const [bg, bt, br, bb, bl, widow, marginRight, keepNext, keepLines, writingMode] = spec.split('|'); const props: string[] = []; if (bg) props.push(`fo:background-color="${bg}"`); if (bt) props.push(`fo:border-top="${bt}"`); @@ -1385,6 +1398,7 @@ function boxSpecToProps(spec: string): string { if (marginRight) props.push(`fo:margin-right="${marginRight}"`); if (keepNext === 'k1') props.push('fo:keep-with-next="always"'); if (keepLines === 'g1') props.push('fo:keep-together="always"'); + if (writingMode) props.push(`style:writing-mode="${writingMode}"`); return props.join(' '); } diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index abcaada..d1ca89d 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -491,6 +491,10 @@ const de: Messages = { title: 'Absatz', indentsTab: 'Einzüge und Abstände', breaksTab: 'Zeilen- und Seitenumbruch', + textDirection: 'Textfluss', + dirPage: 'Wie die Seite', + dirLtr: 'Von links nach rechts', + dirRtl: 'Von rechts nach links', widowControl: 'Absatzkontrolle', keepNext: 'Nicht vom nächsten Absatz trennen', keepLines: 'Zeilen nicht trennen', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 641de0c..c14e682 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -495,6 +495,10 @@ const en = { title: 'Paragraph', indentsTab: 'Indents and spacing', breaksTab: 'Line and page breaks', + textDirection: 'Text direction', + dirPage: 'Same as page', + dirLtr: 'Left-to-right', + dirRtl: 'Right-to-left', widowControl: 'Widow/orphan control', keepNext: 'Keep with next', keepLines: 'Keep lines together', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index b94fd2e..666825f 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -58,6 +58,9 @@ type Ctx = { contentWidthCm: number; // Left page margin (cm), the origin a page-relative frame offset is measured against. leftMarginCm: number; + // The section's own direction: a block declaring the same one is inheriting, not + // formatted, so only a block that differs carries a `dir` attr. + pageRtl: boolean; // The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs. cellSpacing: ParaSpacing; // Whether w:tblInd is measured to the cell's text rather than the table's edge. @@ -172,7 +175,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const sectPr = fc(body, 'sectPr'); const contentWidthCm = sectionContentWidthCm(sectPr); const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440); - const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), notes: [], noteParts: { + const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, pageRtl: sectPrRtl(sectPr), cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), openComments: new Map(), commentDefs: docxComments(files), notes: [], noteParts: { footnote: noteParts(files, 'footnotes', 'footnote'), endnote: noteParts(files, 'endnotes', 'endnote'), } }; @@ -230,8 +233,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = styles: collectStyleSheet(ctx), notes: docNoteSettings(files), 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); })(), + rtl: sectPrRtl(finalSectPr), hyphenate: docAutoHyphenation(files), pageNumbering: docxPageNumbering(sectPr), orientation: sect.orientation, @@ -884,6 +886,11 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: if (!level && (directKn ? onOff(directKn) : ctx.styles.paragraphKeepNext(styleId))) attrs.keepNext = true; const directKl = fc(ppr, 'keepLines'); if (!level && (directKl ? onOff(directKl) : ctx.styles.paragraphKeepLines(styleId))) attrs.keepLines = true; + // w:bidi — the block's own base direction; the section's own is inheritance, not + // formatting, so only a block that differs from it carries the attr. + const directBidi = fc(ppr, 'bidi'); + const bidi = directBidi ? onOff(directBidi) : ctx.styles.paragraphBidi(styleId); + if (bidi != null && bidi !== ctx.pageRtl) attrs.dir = bidi ? 'rtl' : 'ltr'; // Tab stops: a direct w:tabs replaces the style's, which the resolver walks for. const directTabs = fc(ppr, 'tabs'); const stops = formatTabStops(directTabs ? readTabStops(directTabs) : ctx.styles.paragraphTabs(styleId)); @@ -2108,6 +2115,13 @@ function splitBodySections(children: Element[]): { return { groups, midSectPrs }; } +// A right-to-left section (w:bidi): the columns fill from the right, and it is the +// direction a block inherits when it declares none of its own. +function sectPrRtl(sectPr: Element | null): boolean { + const b = fc(sectPr, 'bidi'); + return !!b && onOff(b); +} + // A section break's w:type: 'continuous'/'nextColumn' flow within the page; anything // else (nextPage/oddPage/evenPage, or absent = the default nextPage) begins a new page. function sectionStartsNewPage(sectPr: Element | null): boolean { diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 3df4ea6..e0e68a4 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -178,6 +178,7 @@ export class DocxStyles { private ownContextual = new Map(); // style's own w:pPr/w:contextualSpacing private ownKeepNext = new Map(); // style's own w:pPr/w:keepNext private ownKeepLines = new Map(); // style's own w:pPr/w:keepLines + private ownBidi = new Map(); // style's own w:pPr/w:bidi private ownTabs = new Map(); // style's own w:pPr/w:tabs private ownCellMar = new Map(); // table style's own w:tblPr/w:tblCellMar private ownTblBorders = new Map(); // table style's own w:tblPr/w:tblBorders @@ -252,6 +253,8 @@ export class DocxStyles { if (kn) this.ownKeepNext.set(id, toggle(kn)); const kl = ppr && firstChild(ppr, 'keepLines'); if (kl) this.ownKeepLines.set(id, toggle(kl)); + const bd = ppr && firstChild(ppr, 'bidi'); + if (bd) this.ownBidi.set(id, toggle(bd)); const tabs = ppr && firstChild(ppr, 'tabs'); if (tabs) this.ownTabs.set(id, readTabStops(tabs)); const ind = ppr && firstChild(ppr, 'ind'); @@ -447,6 +450,15 @@ export class DocxStyles { return this.paragraphKeepLines(this.basedOn.get(styleId) ?? null, seen); } + // w:bidi — the style's base direction. null = it declares none, so the section's holds. + paragraphBidi(styleId: string | null | undefined, seen = new Set()): boolean | null { + if (!styleId || seen.has(styleId)) return null; + seen.add(styleId); + const own = this.ownBidi.get(styleId); + if (own != null) return own; + return this.paragraphBidi(this.basedOn.get(styleId) ?? null, seen); + } + private styleWidow(styleId: string | null | undefined, seen = new Set()): boolean | null { if (!styleId || seen.has(styleId)) return null; seen.add(styleId); diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 72b7be4..c3c0443 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -118,6 +118,9 @@ type Ctx = { pendingBlocks: Node[]; // Text width (cm) of the file's page setup; a table's margins are relative to it. contentWidthCm: number; + // The page's own direction: a block declaring the same one is inheriting, not + // formatted, so only a block that differs carries a `dir` attr. + pageRtl: boolean; // Master pages the body switches to, in order — one section each past the first. masterPages: string[]; // How many body blocks each of them governs: the largest count is the document's own @@ -603,7 +606,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const contentWidthCm = geo ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right : pageDimsCm('A4', 'portrait').w - 2 * 2.12; - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), notes: [] }; + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), notes: [] }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm))); @@ -1399,6 +1402,11 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // paragraph carries it — otherwise every heading would accrete the producer's flag. if (!isHeading && paraProps['fo:keep-with-next'] === 'always') attrs.keepNext = true; if (!isHeading && paraProps['fo:keep-together'] === 'always') attrs.keepLines = true; + // style:writing-mode — the block's own base direction. "page" inherits, and the mode + // the page already has is no formatting, so only a block that differs carries it. + const wm = paraProps['style:writing-mode']; + if (wm === 'rl-tb' && !ctx.pageRtl) attrs.dir = 'rtl'; + else if (wm === 'lr-tb' && ctx.pageRtl) attrs.dir = 'ltr'; const markFont = resolver.fontFamilyOf(baseTextProps); if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont; applyUniformRunFont(attrs, content); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 2324c74..6e7ab17 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1913,3 +1913,39 @@ describe('Leg 17: the header row repeats on every page', () => { check('no w:tblHeader', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes(' { + const doc: N = { type: 'doc', content: [ + P(null, T('left to right')), + P({ dir: 'rtl' }, T('טקסט מימין לשמאל')), + { type: 'table', content: [ROW(CELL(null, P({ dir: 'rtl' }, T('בתא'))))] }, + ] }; + + it('ODT: style:writing-mode="rl-tb" on the block, and back', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const content = strFromU8(unzipSync(bytes)['content.xml']); + check('emitted twice (block + cell)', (content.match(/style:writing-mode="rl-tb"/g) ?? []).length === 2, + content.match(/style:writing-mode="[^"]*"/g)); + const back = importOdt(bytes).content as N; + check('plain block stays undirected', back.content[0].attrs?.dir === undefined, back.content[0].attrs); + check('rtl block round-trips', back.content[1].attrs?.dir === 'rtl', back.content[1].attrs); + const cellPara = back.content[2].content[0].content[0].content[0]; + check('cell paragraph round-trips', cellPara.attrs?.dir === 'rtl', cellPara.attrs); + }); + + it('DOCX: w:bidi in w:pPr, and back', async () => { + const bytes = await buildDocx(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + check('emitted twice (block + cell)', (xml.match(//g) ?? []).length === 2, xml.match(/]*>/g)); + const back = importDocx(bytes).content as N; + check('plain block stays undirected', back.content[0].attrs?.dir === undefined, back.content[0].attrs); + check('rtl block round-trips', back.content[1].attrs?.dir === 'rtl', back.content[1].attrs); + }); + + it('an undirected document writes no writing mode at all', async () => { + const plain: N = { type: 'doc', content: [P(null, T('plain'))] }; + const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']); + check('no ODF writing mode', !content.includes('style:writing-mode'), content.match(/style:writing-mode="[^"]*"/g)); + check('no w:bidi', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('