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:
1 parent
cb1c61c900
commit
55f27da855
14 files changed
+174
-11
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] }),
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user