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.
This commit is contained in:
Steffen Becker committed 2026-08-14 06:04:12 +02:00
1 parent cb1c61c900
commit 55f27da855
14 files changed
+174 -11

No files matched your search

+2 -1
View File
@@ -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
+11
View File
@@ -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<string | null>('dir', null) ?? '');
let indent = $derived(read('indent', 0));
let indentRight = $derived(read('indentRight', 0));
let indentFirst = $derived(read('indentFirst', 0));
@@ -88,6 +90,15 @@
</select>
</label>
<label class="row">
<span>{t().paragraphDialog.textDirection}</span>
<select value={dir} onchange={(e) => setAttr('dir', (e.currentTarget as HTMLSelectElement).value || null)}>
<option value="">{t().paragraphDialog.dirPage}</option>
<option value="ltr">{t().paragraphDialog.dirLtr}</option>
<option value="rtl">{t().paragraphDialog.dirRtl}</option>
</select>
</label>
<label class="row newline"><span>{t().ribbon.indentLeft}</span><input type="text" inputmode="decimal" value={num(indent)} onchange={(e) => setNumber('indent', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
<label class="row"><span>{t().ribbon.indentRight}</span><input type="text" inputmode="decimal" value={num(indentRight)} onchange={(e) => setNumber('indentRight', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
<label class="row"><span>{t().ruler.firstLineIndent}</span><input type="text" inputmode="decimal" value={num(indentFirst)} onchange={(e) => setNumber('indentFirst', (e.currentTarget as HTMLInputElement).value)} /><em>cm</em></label>
+3
View File
@@ -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[] }),
+1
View File
@@ -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.
@@ -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<ReturnType> {
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<string, unknown>) =>
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),
};
},
});
+1 -1
View File
@@ -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 `<text:p>` 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 `<text:h>`/`<text:p>`/`<text:list>` (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 `<table:covered-table-cell>` 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 `'<W>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 `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (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 `<draw:frame><draw:object xlink:href="./Formula{n}"/>` 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 `<m:oMath>` (or `<m:oMathPara>` 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 `<w:bidi/>` 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 `<w:bidi/>` 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 `<text:bookmark-start/>`/`<text:bookmark-end/>`/`<text:bookmark-ref>`. 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.
+2
View File
@@ -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,
+19 -5
View File
@@ -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 '<W>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(' ');
}
+4
View File
@@ -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',
+4
View File
@@ -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',
+17 -3
View File
@@ -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 {
+12
View File
@@ -178,6 +178,7 @@ export class DocxStyles {
private ownContextual = new Map<string, boolean>(); // style's own w:pPr/w:contextualSpacing
private ownKeepNext = new Map<string, boolean>(); // style's own w:pPr/w:keepNext
private ownKeepLines = new Map<string, boolean>(); // style's own w:pPr/w:keepLines
private ownBidi = new Map<string, boolean>(); // style's own w:pPr/w:bidi
private ownTabs = new Map<string, TabStop[]>(); // style's own w:pPr/w:tabs
private ownCellMar = new Map<string, Element>(); // table style's own w:tblPr/w:tblCellMar
private ownTblBorders = new Map<string, Element>(); // 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<string>()): 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<string>()): boolean | null {
if (!styleId || seen.has(styleId)) return null;
seen.add(styleId);
+9 -1
View File
@@ -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);
+36
View File
@@ -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('<w:tblHeader'));
});
});
describe('Leg 18: per-paragraph text direction', () => {
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(/<w:bidi\s*\/>/g) ?? []).length === 2, xml.match(/<w:bidi[^>]*>/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('<w:bidi'));
});
});