feat: a tab lands on the file's own grid, and can draw its leader
The default tab interval was fixed at 1.25cm, so a file's own w:defaultTabStop / style:tab-stop-distance was ignored and the DOCX export wrote 1.25 back over Word's 1.27. It is a document setting now. A stop can also carry the character it repeats across its gap. odf-kit has no leader option and mints one automatic style per option set, so the leader rides style:type behind a sentinel — matching on position and alignment alone gave every stop at 6cm the same leader, which LibreOffice drew back plain as day.
This commit is contained in:
1 parent
6accc7268f
commit
ea39dde58b
20 files changed
+319
-39
No files matched your search
@@ -5,7 +5,7 @@ list markers, paragraph boxes and date/time fields.
|
||||
|
||||
- **`textEffects.ts`** (`TextEffects`, `UnderlineStyled`, `StrikeStyled`) — the character effects beyond bold/italic that both formats carry and CSS can draw: `caps` (`uppercase`/`lowercase`/`capitalize`/`smallCaps` → `text-transform`/`font-variant-caps`) and `textPosition` (pt above the baseline → `vertical-align`, independent of sub/superscript) as TextStyle attrs, plus `lineStyle`/`lineColor` on the **underline** mark and `lineStyle` on **strike** (the `<u>`/`<s>` element draws the line, so its shape belongs there). Replaces TipTap's bare Underline/Strike in `extensions.ts` **and** `hfExtensions`. `caps` is also a `TextProps` key, so a named style can carry it. **DOCX**: `w:caps`/`w:smallCaps` (toggles — a run may switch a style's off), `w:u w:val`+`w:color`, `w:dstrike`, `w:position` (`ST_SignedHpsMeasure`: a bare number is half-points, else a measure with its unit). **ODF**: `fo:text-transform`/`fo:font-variant`, `style:text-underline-style`/`-type`/`-color`, `style:text-line-through-type`, `style:text-position` (a percentage of the font size, so the pt value is converted against the run's own size). odf-kit's run formatting has a field for none of them, so `markTextEffects` prefixes the `TEF` sentinel (U+E00F) on the run and `applyTextEffects` folds the attributes into that run's automatic text style — before `applyCharacterStyles`, which then clones the style that already carries them. `tests/lo-roundtrip.test.ts` is what proves LibreOffice reads each one back.
|
||||
- **`paragraphBox.ts`** (`ParagraphBox`) — paragraph/heading **background** (`backgroundColor`, "colored field") + per-side **borders** (`borderTop/Right/Bottom/Left`, "rule line"), as in Word/LibreOffice letterheads. Borders reuse table cells' canonical `'<W>pt solid #RRGGBB'` (from `tableCellBorders.ts`), but here `null`/absent = no border. Commands `setParagraphBackground`/`setParagraphBorders(preset, spec)`; `activeParagraphBorderPresets` drives the picker states. An input rule turns a paragraph typed as `---`/`___`/`===` into an empty bottom-rule line (the border-line AutoCorrect); attrs are `keepOnSplit: false` so the box doesn't cascade onto the next paragraph on Enter. In both `extensions.ts` (body) and `hfExtensions` (header/footer). **ODF**: import reads `fo:background-color`/`fo:border-*` in `blockAttrs` (body) and merges them across the collapsed header/footer paragraphs (`convertHfZone`, first/last heuristic — bottom rule from the last line); export emits them via `ParaStyle`/`paraStyleProps` for cells/lists, a `PBX`-sentinel post-process (`applyParagraphBoxes`, odf-kit has no such options) for top-level body paragraphs, and `applyHfPostProcess` for the Header/Footer styles. **DOCX**: `w:shd`/`w:pBdr` (import `readParaBox`, export `paraShadingOf`/`paraBordersOf`). UI: a paragraph-shading `ColorPicker` + `ParagraphBorderPicker.svelte` in `ToolbarExpanded.svelte` (route via `activeEditor`, so body and the active HF zone).
|
||||
- **`indent.ts`** (`Indent`) — `indent` attr in **cm** (rendered as `margin-left`), on paragraph/heading **and** bulletList/orderedList. `indentMore`/`indentLess` step a paragraph/heading by `INDENT_STEP_CM = 1.25` → `fo:margin-left` (odf-kit `indentLeft`); `indentListMore`/`indentListLess` step the innermost list's `indent`, shifting the whole list (export adds it to the L# list-style level margins, see `applyListIndents`). `indentListForward`/`indentListBackward` apply one Word/LibreOffice step to the list point (bullet + text): **forward** shifts the whole list when the cursor is in the first item or the whole list is selected (`indentListMore`), otherwise nests the item (`sinkListItem`); **backward** is the inverse — it peels back the innermost list's own `indent` first (`indentListLess`), then lifts a *nested* item out one level (`liftListItem`), and at the base level (no margin, not nested) does nothing. So a list point steps left one tab-stop at a time and never gets dissolved into a paragraph — Shift-Tab keeps list points as list points. They return false outside a list. `indentRight` is the mirror of `indent` (`fo:margin-right` / `w:ind w:right`; ODF export rides the PBX sentinel, since odf-kit has no right-indent option). `setIndent(cm)`/`setIndentRight(cm)`/`setIndentFirst(cm)` write one indent outright (what the ruler drags; the buttons step relatively) — all go through the same `write` walk. The exported `listContext(state)` (cursor in a list? whole list targeted? innermost list indent? list nested inside a parent list item?) is their shared helper. Both the toolbar's `changeIndent` and the **Tab**/**Shift-Tab** keymap call these: in a list Tab/Shift-Tab run `indentListForward`/`indentListBackward`; outside a list Tab inserts a real tab character (`\t`, rendered via `tab-size: 1.25cm`) and Shift-Tab runs `indentLess` (`changeIndent` uses `indentMore`/`indentLess`). The keymap bails inside table cells so the Table extension's Tab keeps navigating between cells. `Indent` sets `priority: 1000` so its Tab/Shift-Tab win over `ListItem`'s built-in sink/lift bindings — without it, TipTap's reverse-then-priority-sort lets the later-registered `ListItem` handle the key first (its `liftListItem` would dissolve a top-level list item into a paragraph on Shift-Tab).
|
||||
- **`tabStops.ts`** (`TabStops`) — per-paragraph tab stops (Word's `w:tabs`, ODF `style:tab-stops`). Global attr `tabStops` on paragraph/heading, rendered as `data-tab-stops`; canonical string `'<cm><align code>'` per stop, `;`-separated (`'6c;12r;16d'`, codes `l|c|r|d`), parsed only by the exported `parseTabStops`/`formatTabStops`. Positions are **cm from the left text margin** — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. **Rendering**: CSS only has the fixed `tab-size` grid, so a rAF measuring plugin (shaped like `pageBreaks.ts`, whose `FORCE_PAGE_RECALC` it both listens to and fires) computes each tab's advance from `coordsAtPos` against `.tiptap`'s content edge and emits `Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})` — `tab-size:0` collapses the tab's own advance, the inline margin supplies the exact width, so no `display` change and no baseline/line-height side effects. The margin is on the **left** because the gap is the tab's own advance: as `margin-right` a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen position is read with **side −1** (the end of the content before the tab), which lies outside the tab's own span — measuring the tab itself would fold the margin just applied back into the next pass's reading. `nextStop` takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A **negative `indentFirst` implies a stop at the block's indent** (the hanging-indent stop). Centre/right/decimal measure the segment up to the next tab; a segment that wraps degrades to left. Passes converge (one more tab settles per pass, `MAX_PASSES`), since a stop can rewrap the line. **UI**: `Ruler.svelte`. Body only — an inactive header/footer zone is static `generateHTML` output no plugin reaches.
|
||||
- **`indent.ts`** (`Indent`) — `indent` attr in **cm** (rendered as `margin-left`), on paragraph/heading **and** bulletList/orderedList. `indentMore`/`indentLess` step a paragraph/heading by `INDENT_STEP_CM = 1.25` → `fo:margin-left` (odf-kit `indentLeft`); `indentListMore`/`indentListLess` step the innermost list's `indent`, shifting the whole list (export adds it to the L# list-style level margins, see `applyListIndents`). `indentListForward`/`indentListBackward` apply one Word/LibreOffice step to the list point (bullet + text): **forward** shifts the whole list when the cursor is in the first item or the whole list is selected (`indentListMore`), otherwise nests the item (`sinkListItem`); **backward** is the inverse — it peels back the innermost list's own `indent` first (`indentListLess`), then lifts a *nested* item out one level (`liftListItem`), and at the base level (no margin, not nested) does nothing. So a list point steps left one tab-stop at a time and never gets dissolved into a paragraph — Shift-Tab keeps list points as list points. They return false outside a list. `indentRight` is the mirror of `indent` (`fo:margin-right` / `w:ind w:right`; ODF export rides the PBX sentinel, since odf-kit has no right-indent option). `setIndent(cm)`/`setIndentRight(cm)`/`setIndentFirst(cm)` write one indent outright (what the ruler drags; the buttons step relatively) — all go through the same `write` walk. The exported `listContext(state)` (cursor in a list? whole list targeted? innermost list indent? list nested inside a parent list item?) is their shared helper. Both the toolbar's `changeIndent` and the **Tab**/**Shift-Tab** keymap call these: in a list Tab/Shift-Tab run `indentListForward`/`indentListBackward`; outside a list Tab inserts a real tab character (`\t`, rendered via the document's `tab-size`) and Shift-Tab runs `indentLess` (`changeIndent` uses `indentMore`/`indentLess`). The keymap bails inside table cells so the Table extension's Tab keeps navigating between cells. `Indent` sets `priority: 1000` so its Tab/Shift-Tab win over `ListItem`'s built-in sink/lift bindings — without it, TipTap's reverse-then-priority-sort lets the later-registered `ListItem` handle the key first (its `liftListItem` would dissolve a top-level list item into a paragraph on Shift-Tab).
|
||||
- **`tabStops.ts`** (`TabStops`) — per-paragraph tab stops (Word's `w:tabs`, ODF `style:tab-stops`). Global attr `tabStops` on paragraph/heading, rendered as `data-tab-stops`; canonical string `'<cm><align code>'` per stop, `;`-separated (`'6c;12r;16d'`, codes `l|c|r|d`), parsed only by the exported `parseTabStops`/`formatTabStops`. Positions are **cm from the left text margin** — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. **Rendering**: CSS only has the fixed `tab-size` grid, so a rAF measuring plugin (shaped like `pageBreaks.ts`, whose `FORCE_PAGE_RECALC` it both listens to and fires) computes each tab's advance from `coordsAtPos` against `.tiptap`'s content edge and emits `Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})` — `tab-size:0` collapses the tab's own advance, the inline margin supplies the exact width, so no `display` change and no baseline/line-height side effects. The margin is on the **left** because the gap is the tab's own advance: as `margin-right` a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen position is read with **side −1** (the end of the content before the tab), which lies outside the tab's own span — measuring the tab itself would fold the margin just applied back into the next pass's reading. `nextStop` takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A **negative `indentFirst` implies a stop at the block's indent** (the hanging-indent stop). Centre/right/decimal measure the segment up to the next tab; a segment that wraps degrades to left. Passes converge (one more tab settles per pass, `MAX_PASSES`), since a stop can rewrap the line. **UI**: `Ruler.svelte`. Body only — an inactive header/footer zone is static `generateHTML` output no plugin reaches. A stop may carry a **leader** (`w:tab w:leader` / `style:leader-text`, one of `. - _ ·`), drawn as a `::before` clipped to the measured gap so the fill stays out of the document's text; on the ODF side odf-kit has no leader option and mints one automatic style per option set, so the leader rides `style:type` behind a `LEAD` sentinel that `applyTabLeaders` unpacks — a bare position+type lookup would give two stops that agree on both the same leader. Past the last custom stop the grid is the document's own **default tab interval** (`storage/tabInterval.ts`, LibreOffice's 1.25cm; Word's is 1.27): imported from `w:defaultTabStop` / the paragraph default-style's `style:tab-stop-distance`, applied as the `--tab-interval` CSS var, and written back on export — the ODF side only when it differs from LibreOffice's, so a file that never declared one doesn't gain it.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **absolutely positioned** at that indent, because in flow it grew every list line by a pixel. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs.
|
||||
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
|
||||
+13
-3
@@ -23,6 +23,7 @@
|
||||
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, loadRuler, saveRuler, type ThemeMode } from './lib/storage/theme';
|
||||
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
|
||||
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
|
||||
import { loadTabInterval, saveTabInterval, applyTabIntervalVar, DEFAULT_TAB_INTERVAL_CM } from './lib/storage/tabInterval';
|
||||
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
|
||||
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
|
||||
import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet';
|
||||
@@ -120,6 +121,7 @@
|
||||
let pageMargins: PageMargins = $state(loadPageMargins());
|
||||
let pageOrientation: Orientation = $state(loadOrientation());
|
||||
let pageFormat: PageFormat = $state(loadPageFormat());
|
||||
let tabIntervalCm = $state(loadTabInterval());
|
||||
|
||||
// The document's spell-check language; round-trips through the .odt. The effect
|
||||
// below persists it and switches the shared spell controller (loads the dict).
|
||||
@@ -172,6 +174,11 @@
|
||||
savePageFormat(pageFormat);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveTabInterval(tabIntervalCm);
|
||||
applyTabIntervalVar(tabIntervalCm);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
saveDocumentLanguage(documentLanguage);
|
||||
void spellController.setLanguage(documentLanguage);
|
||||
@@ -440,6 +447,7 @@
|
||||
pageMargins = { ...DEFAULT_MARGINS };
|
||||
pageOrientation = 'portrait';
|
||||
pageFormat = 'A4';
|
||||
tabIntervalCm = DEFAULT_TAB_INTERVAL_CM;
|
||||
hfDistances = { ...DEFAULT_HF_DISTANCES };
|
||||
extraHfSections = [];
|
||||
documentName = '';
|
||||
@@ -496,6 +504,7 @@
|
||||
if (result.margins) pageMargins = result.margins;
|
||||
if (result.orientation) pageOrientation = result.orientation;
|
||||
if (result.format) pageFormat = result.format;
|
||||
if (result.tabIntervalCm) tabIntervalCm = result.tabIntervalCm;
|
||||
// Adopt the document's spell-check language (the $effect switches the
|
||||
// controller + loads its dictionary). null = file declared none; keep ours.
|
||||
if (result.language) documentLanguage = result.language;
|
||||
@@ -574,7 +583,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm);
|
||||
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -590,7 +599,7 @@
|
||||
exportMenuOpen = false;
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
try {
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
|
||||
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm);
|
||||
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -608,7 +617,7 @@
|
||||
try {
|
||||
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
|
||||
const { buildDocx } = await import('./lib/export/docx');
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
|
||||
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm);
|
||||
await saveAsDocx(bytes, suggestedFilenameDocx(json));
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return;
|
||||
@@ -1035,6 +1044,7 @@
|
||||
bind:hfActive
|
||||
bind:hfTick
|
||||
{hfDistances}
|
||||
{tabIntervalCm}
|
||||
{extraHfSections}
|
||||
{zoom}
|
||||
onZoom={setZoom}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import Ruler from './Ruler.svelte';
|
||||
import { saveDocument, loadDocument } from '../storage/autosave';
|
||||
import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||||
import { type Orientation } from '../storage/pageOrientation';
|
||||
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_HF_DISTANCES, hfIsEmpty, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
|
||||
@@ -36,7 +37,7 @@
|
||||
let {
|
||||
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
|
||||
zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
|
||||
pageFormat = 'A4',
|
||||
pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM,
|
||||
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
|
||||
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
|
||||
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false,
|
||||
@@ -45,7 +46,7 @@
|
||||
}: {
|
||||
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
|
||||
onZoom?: (zoom: number) => void;
|
||||
showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat;
|
||||
showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number;
|
||||
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
|
||||
headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean;
|
||||
headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean;
|
||||
@@ -126,6 +127,8 @@
|
||||
void (pageMargins.top + pageMargins.bottom + pageMargins.left + pageMargins.right);
|
||||
void orientation;
|
||||
void pageFormat;
|
||||
// …the tab grid, which changes where a tab lands and so where a line breaks…
|
||||
void tabIntervalCm;
|
||||
// …and the header/footer-driven effective margins, so growing a zone re-paginates.
|
||||
void (effTopRest + effTopFirst + effBottomRest + effBottomFirst);
|
||||
void sectionReach;
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
strip.releasePointerCapture(event.pointerId);
|
||||
if (target.kind === 'stop') {
|
||||
const rest = info.stops.filter((_, i) => i !== target.index);
|
||||
apply(off ? rest : [...rest, { pos: cm, align: info.stops[target.index].align }]);
|
||||
// Dragging moves the stop, so it keeps its alignment and its leader.
|
||||
apply(off ? rest : [...rest, { ...info.stops[target.index], pos: cm }]);
|
||||
} else if (target.kind === 'indent') {
|
||||
// Moves the whole block: the first line keeps its offset from the indent.
|
||||
editor?.chain().focus().setIndent(Math.max(0, cm)).run();
|
||||
|
||||
@@ -10,30 +10,39 @@ import { PX_PER_CM } from '../../storage/pageMargins';
|
||||
// resolves to a stop is measured and given the exact advance as an inline margin.
|
||||
|
||||
export type TabAlign = 'left' | 'center' | 'right' | 'decimal';
|
||||
export type TabStop = { pos: number; align: TabAlign };
|
||||
export type TabStop = { pos: number; align: TabAlign; leader?: string | null };
|
||||
|
||||
const CODE: Record<TabAlign, string> = { left: 'l', center: 'c', right: 'r', decimal: 'd' };
|
||||
const ALIGN: Record<string, TabAlign> = { l: 'left', c: 'center', r: 'right', d: 'decimal' };
|
||||
|
||||
// Canonical attr form: one '<cm><align code>' per stop, ';'-separated ('6c;12r;16d').
|
||||
// Positions are cm from the left text margin — the origin Word and ODF share.
|
||||
// The fill characters a stop may repeat across its gap. Anything else a file names is
|
||||
// dropped rather than approximated, so the gap simply stays blank.
|
||||
export const LEADER_CHARS = ['.', '-', '_', '·'] as const;
|
||||
|
||||
export function normalizeLeader(ch: unknown): string | null {
|
||||
return typeof ch === 'string' && (LEADER_CHARS as readonly string[]).includes(ch) ? ch : null;
|
||||
}
|
||||
|
||||
// Canonical attr form: one '<cm><align code><leader?>' per stop, ';'-separated
|
||||
// ('6c;12r.;16d'). Positions are cm from the left text margin — Word's origin and ODF's.
|
||||
export function parseTabStops(value: unknown): TabStop[] {
|
||||
if (typeof value !== 'string' || !value) return [];
|
||||
const out: TabStop[] = [];
|
||||
for (const part of value.split(';')) {
|
||||
const m = /^(-?\d*\.?\d+)([lcrd])$/.exec(part.trim());
|
||||
if (m) out.push({ pos: parseFloat(m[1]), align: ALIGN[m[2]] });
|
||||
const m = /^(-?\d*\.?\d+)([lcrd])(\S)?$/.exec(part.trim());
|
||||
if (m) out.push({ pos: parseFloat(m[1]), align: ALIGN[m[2]], leader: normalizeLeader(m[3]) });
|
||||
}
|
||||
return out.sort((a, b) => a.pos - b.pos);
|
||||
}
|
||||
|
||||
export function formatTabStops(stops: TabStop[]): string | null {
|
||||
const byPos = new Map<number, TabAlign>();
|
||||
const byPos = new Map<number, TabStop>();
|
||||
for (const s of stops) {
|
||||
const pos = Math.round(s.pos * 100) / 100;
|
||||
if (pos >= 0) byPos.set(pos, s.align);
|
||||
if (pos >= 0) byPos.set(pos, { ...s, pos });
|
||||
}
|
||||
const out = [...byPos.entries()].sort((a, b) => a[0] - b[0]).map(([pos, align]) => `${pos}${CODE[align]}`);
|
||||
const out = [...byPos.values()].sort((a, b) => a.pos - b.pos)
|
||||
.map((s) => `${s.pos}${CODE[s.align]}${normalizeLeader(s.leader) ?? ''}`);
|
||||
return out.length ? out.join(';') : null;
|
||||
}
|
||||
|
||||
@@ -92,7 +101,7 @@ function tabPositions(node: PmNode, blockPos: number): number[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
type TabWidth = { pos: number; width: number };
|
||||
type TabWidth = { pos: number; width: number; leader: string | null };
|
||||
|
||||
function measure(view: EditorView): TabWidth[] {
|
||||
const dom = view.dom as HTMLElement;
|
||||
@@ -138,7 +147,7 @@ function measure(view: EditorView): TabWidth[] {
|
||||
width -= back;
|
||||
}
|
||||
}
|
||||
out.push({ pos: tabPos, width: Math.max(0, Math.round(width * 100) / 100) });
|
||||
out.push({ pos: tabPos, width: Math.max(0, Math.round(width * 100) / 100), leader: normalizeLeader(stop.leader) });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -242,14 +251,18 @@ export const TabStops = Extension.create({
|
||||
// coordsAtPos throws on a position the browser hasn't rendered yet; the
|
||||
// next change re-runs the pass anyway.
|
||||
try { widths = measure(view); } catch { return; }
|
||||
const next = widths.map((w) => `${w.pos}:${w.width}`).join(',');
|
||||
const next = widths.map((w) => `${w.pos}:${w.width}:${w.leader ?? ''}`).join(',');
|
||||
if (next === key) return;
|
||||
key = next;
|
||||
const decos = widths.map((w) =>
|
||||
// margin-LEFT: the gap is the tab's own advance, so a caret placed after
|
||||
// the tab has to sit behind it. As margin-right it stayed at the old x
|
||||
// until the next keystroke moved it into the following text node.
|
||||
Decoration.inline(w.pos, w.pos + 1, { style: `tab-size:0;margin-left:${w.width}px` }),
|
||||
Decoration.inline(w.pos, w.pos + 1, w.leader
|
||||
// The fill is a ::before clipped to the gap (editor.css), so the leader
|
||||
// stays out of the document's text.
|
||||
? { style: `tab-size:0;margin-left:${w.width}px;--leader-w:${w.width}px`, class: 'tab-leader', 'data-leader': w.leader }
|
||||
: { style: `tab-size:0;margin-left:${w.width}px` }),
|
||||
);
|
||||
// The advances change line breaking, so pagination has to re-measure.
|
||||
view.dispatch(view.state.tr
|
||||
|
||||
@@ -41,4 +41,5 @@ The filename is derived from the first non-empty heading (max 50 chars, sanitize
|
||||
Sentinel order matters — the passes assume it. Full list of sentinels in use:
|
||||
`LBR`/`TAB` (inline), `IMG`, `SEG` (cell blocks), `TBX` U+E008 (text boxes),
|
||||
U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
|
||||
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes).
|
||||
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes), `SEC` U+E010
|
||||
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`).
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
|
||||
WidthType, HeightRule, PageOrientation, LineRuleType, TableLayoutType, SectionType,
|
||||
HorizontalPositionAlign, VerticalPositionRelativeFrom, HorizontalPositionRelativeFrom,
|
||||
TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType,
|
||||
TextWrappingType, TextWrappingSide, ImportedXmlComponent, TabStopType, LeaderType,
|
||||
} from 'docx';
|
||||
import type { TiptapNode } from 'odf-kit';
|
||||
import type {
|
||||
@@ -17,6 +17,7 @@ import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
|
||||
@@ -77,6 +78,10 @@ const DOCX_TAB_TYPE: Record<TabAlign, (typeof TabStopType)[keyof typeof TabStopT
|
||||
left: TabStopType.LEFT, center: TabStopType.CENTER,
|
||||
right: TabStopType.RIGHT, decimal: TabStopType.DECIMAL,
|
||||
};
|
||||
// The fill character a stop repeats across its gap; anything else means none.
|
||||
const DOCX_LEADER: Record<string, (typeof LeaderType)[keyof typeof LeaderType] | undefined> = {
|
||||
'.': LeaderType.DOT, '-': LeaderType.HYPHEN, '_': LeaderType.UNDERSCORE, '·': LeaderType.MIDDLE_DOT,
|
||||
};
|
||||
const EMU_PER_PX = 9525;
|
||||
const EMU_PER_PT = 12700;
|
||||
const EMU_PER_CM = 360000;
|
||||
@@ -700,7 +705,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph {
|
||||
keepNext: attrs.keepNext === true || undefined,
|
||||
keepLines: attrs.keepLines === true || undefined,
|
||||
tabStops: stops.length
|
||||
? stops.map((s) => ({ type: DOCX_TAB_TYPE[s.align], position: cmToTwip(s.pos) }))
|
||||
? stops.map((s) => ({ type: DOCX_TAB_TYPE[s.align], position: cmToTwip(s.pos), leader: DOCX_LEADER[s.leader ?? ''] }))
|
||||
: undefined,
|
||||
numbering: opts.numbering,
|
||||
shading: paraShadingOf(attrs),
|
||||
@@ -1090,6 +1095,7 @@ export async function buildDocx(
|
||||
language?: { language: string; country: string } | null,
|
||||
pageFormat: PageFormat = 'A4',
|
||||
styles: StyleSheet = builtinStyleSheet(),
|
||||
tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM,
|
||||
): Promise<Uint8Array> {
|
||||
docLangTag = localeTag(language ? language.language : 'en');
|
||||
exportSheet = styles;
|
||||
@@ -1160,7 +1166,7 @@ export async function buildDocx(
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'Web ODF Editor',
|
||||
defaultTabStop: cmToTwip(1.25),
|
||||
defaultTabStop: cmToTwip(tabIntervalCm),
|
||||
...(differentOddEven ? { evenAndOddHeaderAndFooters: true } : {}),
|
||||
...(hasToc ? { features: { updateFields: true } } : {}),
|
||||
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
|
||||
|
||||
+40
-7
@@ -3,6 +3,7 @@ import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
|
||||
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
|
||||
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
|
||||
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
|
||||
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
|
||||
import { parseTabStops } from '../editor/extensions/tabStops';
|
||||
import { normalizeLeader, parseTabStops } from '../editor/extensions/tabStops';
|
||||
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
|
||||
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
|
||||
@@ -135,6 +136,10 @@ const TEF = '\uE00F';
|
||||
// which is what carries the section's own header/footer. U+E010.
|
||||
const SEC = '\uE010';
|
||||
|
||||
// Separates a tab stop's alignment from its leader character inside style:type,
|
||||
// which odf-kit writes verbatim. U+E011.
|
||||
const LEAD = '\uE011';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -1994,7 +1999,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
|
||||
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
|
||||
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
|
||||
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set()): Uint8Array {
|
||||
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>, usedTables: Set<string> = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const stylesBytes = files['styles.xml'];
|
||||
if (!stylesBytes) return odtBytes;
|
||||
@@ -2028,6 +2033,13 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr
|
||||
'<style:master-page style:name="Standard"',
|
||||
);
|
||||
|
||||
// The document's tab interval, on the paragraph default-style. Written only when it
|
||||
// differs from LibreOffice's own, so a file that never declared one doesn't gain it.
|
||||
if (Math.abs(tabIntervalCm - DEFAULT_TAB_INTERVAL_CM) > 0.001) {
|
||||
styles = styles.replace('<office:styles>', '<office:styles>'
|
||||
+ `<style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="${round3(tabIntervalCm)}cm"/></style:default-style>`);
|
||||
}
|
||||
|
||||
// odf-kit's Standard style carries fo:margin-bottom="0.212cm", but the editor (like
|
||||
// LibreOffice and Word) has no paragraph spacing by default — every paragraph and list
|
||||
// item inherits this, so zero it or the export gains ~6pt per block vs. the preview.
|
||||
@@ -2576,6 +2588,24 @@ function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
|
||||
// inside <text:span>), and complete the decimal stop odf-kit only half-writes.
|
||||
const CHAR_TAB_STOP = 'style:type="char"';
|
||||
|
||||
// The ODF line kind that goes with a fill character.
|
||||
const ODF_LEADER_STYLE: Record<string, string> = { '.': 'dotted', '·': 'dotted', '-': 'dash', '_': 'solid' };
|
||||
|
||||
// odf-kit has no leader option and mints one automatic style per distinct option set,
|
||||
// so the leader rides style:type — where a bare map would give two stops that agree on
|
||||
// position and alignment the same style, and with it the same leader.
|
||||
function applyTabLeaders(odtBytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
let content = strFromU8(contentBytes);
|
||||
if (!content.includes(LEAD)) return odtBytes;
|
||||
content = content.replace(new RegExp(`style:type="([a-z]+)${LEAD}(.)"`, 'g'), (_m, type, ch) =>
|
||||
`style:type="${type}" style:leader-style="${ODF_LEADER_STYLE[ch]}" style:leader-text="${ch}"`);
|
||||
files['content.xml'] = strToU8(content);
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
@@ -3005,7 +3035,7 @@ export type HfExport = {
|
||||
};
|
||||
|
||||
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet()): Promise<Uint8Array> {
|
||||
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM): Promise<Uint8Array> {
|
||||
// Images become IMG sentinels before serialization; applyImages resolves them and writes
|
||||
// the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks
|
||||
// (so PGB misses their blocks) and before the inline passes (which then cover them).
|
||||
@@ -3130,8 +3160,10 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
if (stops.length) {
|
||||
opts.tabStops = stops.map((s) => ({
|
||||
position: `${s.pos}cm`,
|
||||
// odf-kit writes style:type verbatim but types it without ODF's 'char'.
|
||||
type: (s.align === 'decimal' ? 'char' : s.align) as 'left' | 'center' | 'right',
|
||||
// odf-kit writes style:type verbatim but types it without ODF's 'char'; a
|
||||
// LEAD suffix rides along so the leader reaches applyTabLeaders.
|
||||
type: ((s.align === 'decimal' ? 'char' : s.align)
|
||||
+ (normalizeLeader(s.leader) ? `${LEAD}${s.leader}` : '')) as 'left' | 'center' | 'right',
|
||||
}));
|
||||
}
|
||||
const content = node.content ?? [];
|
||||
@@ -3209,7 +3241,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
applyTableMargins(styledRows, tableMargins, contentWidthCm), tableStyleNames);
|
||||
|
||||
const cleaned = collapseRunWhitespace(withTableMargins);
|
||||
const withBreaks = applyInlineSentinels(cleaned);
|
||||
// Before applyInlineSentinels: it matches the bare style:type="char" a leader would hide.
|
||||
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
|
||||
const withImages = applyImages(withBreaks, images);
|
||||
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
|
||||
const withTextBoxes = applyTextBoxes(withDateFields, textBoxes);
|
||||
@@ -3221,7 +3254,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
// Effects first: applyCharacterStyles then clones the style that already carries them.
|
||||
const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes)));
|
||||
const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name));
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables);
|
||||
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm);
|
||||
const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
|
||||
// Sections past the first get their own master page, which is where ODF keeps a
|
||||
// section's header/footer; the SEC-marked block points at it.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFor
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
import { clampTabInterval } from '../storage/tabInterval';
|
||||
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
|
||||
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { applyUniformRunFont, type OdtImportResult } from './odt';
|
||||
@@ -176,6 +177,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
margins: sect.margins,
|
||||
orientation: sect.orientation,
|
||||
format: sect.format,
|
||||
tabIntervalCm: docTabInterval(files),
|
||||
header: first.header,
|
||||
footer: first.footer,
|
||||
headerFirst: first.differentFirstPage ? first.headerFirst : null,
|
||||
@@ -1668,6 +1670,19 @@ function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// The grid every tab past the last custom stop falls on, also from settings.xml.
|
||||
function docTabInterval(files: Record<string, Uint8Array>): number | null {
|
||||
const bytes = files['word/settings.xml'];
|
||||
if (!bytes) return null;
|
||||
try {
|
||||
const el = parseXml(strFromU8(bytes)).getElementsByTagNameNS(W, 'defaultTabStop')[0];
|
||||
const tw = el && intAttr(el, W, 'val');
|
||||
return tw ? clampTabInterval(round2(twipToCm(tw))) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSectPr(sect: Element | null, ctx: Ctx, oddEven = false): {
|
||||
margins: PageMargins | null; orientation: Orientation | null; format: PageFormat | null;
|
||||
header: HfDoc; footer: HfDoc; headerFirst: HfDoc; footerFirst: HfDoc; differentFirstPage: boolean;
|
||||
|
||||
@@ -51,6 +51,12 @@ export function wVal(el: Element): string | null {
|
||||
|
||||
// <w:tabs> → stops in cm from the left text margin, w:pos's origin (which the editor's
|
||||
// attr shares). 'clear' removes an inherited stop and 'bar' is a rule, not a stop.
|
||||
// w:leader → the fill character. "heavy" is a bold underscore, which we can only
|
||||
// render as the plain one.
|
||||
const DOCX_LEADER: Record<string, string> = {
|
||||
dot: '.', hyphen: '-', underscore: '_', heavy: '_', middleDot: '·',
|
||||
};
|
||||
|
||||
export function readTabStops(tabs: Element): TabStop[] {
|
||||
const out: TabStop[] = [];
|
||||
for (const tab of Array.from(tabs.getElementsByTagNameNS(W, 'tab'))) {
|
||||
@@ -61,7 +67,7 @@ export function readTabStops(tabs: Element): TabStop[] {
|
||||
const align: TabAlign = val === 'center' ? 'center'
|
||||
: val === 'right' || val === 'end' ? 'right'
|
||||
: val === 'decimal' ? 'decimal' : 'left';
|
||||
out.push({ pos: Math.round((pos / 1440) * 2.54 * 100) / 100, align });
|
||||
out.push({ pos: Math.round((pos / 1440) * 2.54 * 100) / 100, align, leader: DOCX_LEADER[tab.getAttributeNS(W, 'leader') ?? ''] ?? null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface OdtImportResult {
|
||||
margins: PageMargins | null;
|
||||
orientation: Orientation | null;
|
||||
format: PageFormat | null;
|
||||
// The grid every tab past the last custom stop falls on; null = the file declares none.
|
||||
tabIntervalCm: number | null;
|
||||
// Single-paragraph docs in the hfExtensions schema; null = no zone.
|
||||
header: HfDoc;
|
||||
footer: HfDoc;
|
||||
@@ -455,6 +457,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
|
||||
margins: geometry?.margins ?? null,
|
||||
orientation: geometry?.orientation ?? null,
|
||||
format: geometry?.format ?? null,
|
||||
tabIntervalCm: resolver.defaultTabInterval(),
|
||||
header,
|
||||
footer,
|
||||
headerFirst,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { formatFromCm, type PageFormat } from '../storage/pageFormat';
|
||||
import type { TabAlign } from '../editor/extensions/tabStops';
|
||||
import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops';
|
||||
|
||||
// Resolves ODF style indirection for the importer: producers (our export, LibreOffice,
|
||||
// Word) spread formatting across named/automatic styles and parent-style-name chains.
|
||||
@@ -124,6 +124,9 @@ function entryFromStyleElement(el: Element): StyleEntry {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// style:leader-style, for a file that names the line kind but no leader text.
|
||||
const ODF_LEADER: Record<string, string> = { dotted: '.', dash: '-', solid: '_' };
|
||||
|
||||
export class StyleResolver {
|
||||
// (family + '\0' + name) → entry; later registrations win (content.xml
|
||||
// automatic styles override styles.xml ones on name collision).
|
||||
@@ -315,6 +318,12 @@ export class StyleResolver {
|
||||
return { language, country: props['fo:country'] ?? '' };
|
||||
}
|
||||
|
||||
// The grid every tab past the last custom stop falls on, from the paragraph
|
||||
// default-style (Standard may override it). null when the file declares none.
|
||||
defaultTabInterval(): number | null {
|
||||
return lengthToCm(this.merged('paragraph', 'Standard').para['style:tab-stop-distance']);
|
||||
}
|
||||
|
||||
// Resolve a text-props map's font: fo:font-family wins, else style:font-name
|
||||
// through the font-face declarations.
|
||||
fontFamilyOf(props: PropMap): string | null {
|
||||
@@ -393,7 +402,7 @@ export class StyleResolver {
|
||||
// Tab stops of a paragraph style, in cm from the left text margin (the origin Word
|
||||
// and ODF share). ODF replaces the list rather than merging it, so the nearest
|
||||
// declaration in the parent chain wins outright.
|
||||
tabStops(styleName: string | null): { pos: number; align: TabAlign }[] {
|
||||
tabStops(styleName: string | null): TabStop[] {
|
||||
const seen = new Set<string>();
|
||||
let cur = styleName;
|
||||
while (cur && !seen.has(cur)) {
|
||||
@@ -401,14 +410,18 @@ export class StyleResolver {
|
||||
const el = this.paraStyleEls.get(cur);
|
||||
const list = el?.getElementsByTagNameNS(NS.style, 'tab-stops')[0];
|
||||
if (list) {
|
||||
const out: { pos: number; align: TabAlign }[] = [];
|
||||
const out: TabStop[] = [];
|
||||
for (const stop of Array.from(list.getElementsByTagNameNS(NS.style, 'tab-stop'))) {
|
||||
const pos = lengthToCm(stop.getAttributeNS(NS.style, 'position'));
|
||||
if (pos == null) continue;
|
||||
const type = stop.getAttributeNS(NS.style, 'type');
|
||||
const align: TabAlign = type === 'char' ? 'decimal'
|
||||
: type === 'center' || type === 'right' ? type : 'left';
|
||||
out.push({ pos, align });
|
||||
// The fill character is the leader text; style:leader-style stands in where
|
||||
// the file names only the line kind (LibreOffice writes both).
|
||||
const leader = stop.getAttributeNS(NS.style, 'leader-text')
|
||||
?? ODF_LEADER[stop.getAttributeNS(NS.style, 'leader-style') ?? ''] ?? null;
|
||||
out.push({ pos, align, leader: normalizeLeader(leader) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## Page layout settings
|
||||
|
||||
- **`pageMargins.ts`** — `PageMargins` in **cm** (default `{ top: 2, bottom: 2, left: 2, right: 2 }` — LibreOffice's; clamped 0–10). `applyMarginVars` sets `--user-margin-*` (px) on `:root`; `PX_PER_CM = 96/2.54`.
|
||||
- **`tabInterval.ts`** — the document's default tab interval in **cm** (`DEFAULT_TAB_INTERVAL_CM = 1.25`, LibreOffice's; Word's is 1.27; clamped 0.05–10). `applyTabIntervalVar` sets `--tab-interval`, which is `.tiptap`'s `tab-size`.
|
||||
- **`pageOrientation.ts`** — `'portrait' | 'landscape'`. `applyOrientationVars` sets `--user-page-{width,height}`; landscape swaps the A4 dimensions (matching odf-kit's automatic swap).
|
||||
|
||||
Both margins and orientation are passed into the ODT export so the exported document's geometry/line-wrapping matches the on-screen preview.
|
||||
@@ -17,6 +18,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
|
||||
- **Zoom:** `odf-editor-zoom` — integer percent.
|
||||
- **Page margins:** `odf-editor-page-margins` — JSON cm values.
|
||||
- **Page orientation:** `odf-editor-page-orientation` — `'portrait' | 'landscape'`.
|
||||
- **Tab interval:** `odf-editor-tab-interval` — cm as a decimal string.
|
||||
- **Recent fonts:** `odf-editor-recent-fonts` — JSON string array (ToolbarExpanded).
|
||||
- **Header/footer:** `odf-editor-header` / `-footer` (HfDoc), `odf-editor-hf-distances`.
|
||||
- **Styles:** `odf-editor-styles` — the style registry (`styles/sheet.svelte.ts`).
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const KEY = 'odf-editor-tab-interval';
|
||||
|
||||
// The grid every tab past the last custom stop falls on (Word w:defaultTabStop, ODF
|
||||
// style:tab-stop-distance). LibreOffice's default; Word's is 1.27cm.
|
||||
export const DEFAULT_TAB_INTERVAL_CM = 1.25;
|
||||
|
||||
const MIN_CM = 0.05;
|
||||
const MAX_CM = 10;
|
||||
|
||||
export function clampTabInterval(cm: unknown): number {
|
||||
if (typeof cm !== 'number' || !Number.isFinite(cm)) return DEFAULT_TAB_INTERVAL_CM;
|
||||
return Math.min(MAX_CM, Math.max(MIN_CM, Math.round(cm * 1000) / 1000));
|
||||
}
|
||||
|
||||
export function loadTabInterval(): number {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw == null ? DEFAULT_TAB_INTERVAL_CM : clampTabInterval(parseFloat(raw));
|
||||
}
|
||||
|
||||
export function saveTabInterval(cm: number): void {
|
||||
localStorage.setItem(KEY, String(cm));
|
||||
}
|
||||
|
||||
// Sets --tab-interval on the document root, where it inherits down to .tiptap and
|
||||
// becomes its `tab-size` (see editor.css).
|
||||
export function applyTabIntervalVar(cm: number): void {
|
||||
document.documentElement.style.setProperty('--tab-interval', `${cm}cm`);
|
||||
}
|
||||
+29
-2
@@ -376,9 +376,36 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
|
||||
/* A tab stop's leader fills the gap the measuring plugin left as margin-left. The span
|
||||
is zero-width (tab-size:0), so its right padding edge is where the stop sits. */
|
||||
.paper .tiptap .tab-leader {
|
||||
position: relative;
|
||||
}
|
||||
.paper .tiptap .tab-leader::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: var(--leader-w, 0);
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
line-height: inherit;
|
||||
pointer-events: none;
|
||||
/* Long enough to outrun any gap A4 can hold; the box clips the rest. */
|
||||
content: '................................................................................................................................................................................................................';
|
||||
}
|
||||
.paper .tiptap .tab-leader[data-leader='-']::before {
|
||||
content: '----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------';
|
||||
}
|
||||
.paper .tiptap .tab-leader[data-leader='_']::before {
|
||||
content: '____________________________________________________________________________________________________________________________________________________________________________________________________________';
|
||||
}
|
||||
.paper .tiptap .tab-leader[data-leader='·']::before {
|
||||
content: '····································································································································································································································';
|
||||
}
|
||||
|
||||
/* The document's own tab interval (storage/tabInterval.ts), LibreOffice's by default. */
|
||||
.paper .tiptap {
|
||||
tab-size: 1.25cm;
|
||||
tab-size: var(--tab-interval, 1.25cm);
|
||||
/* prosemirror-view sets `break-spaces`, which forbids a line-end space from
|
||||
hanging into the margin, so lines break one word earlier than in
|
||||
Word/LibreOffice. `pre-wrap` lets it hang, as they do. */
|
||||
|
||||
@@ -243,4 +243,20 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
|
||||
check('LO hf: header distance ≈0.8cm', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.1, hfRes.headerDistanceCm);
|
||||
check('LO hf: footer distance ≈1.6cm', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.1, hfRes.footerDistanceCm);
|
||||
});
|
||||
|
||||
it('survives a `soffice` re-save of the tab interval and a stop leader', { timeout: 180000 }, async () => {
|
||||
const tabDoc: N = { type: 'doc', content: [
|
||||
{ type: 'paragraph', attrs: { tabStops: '6l.;12r_' }, content: [T('Kapitel\t1\tS. 3')] },
|
||||
] };
|
||||
const bytes = await buildOdt(tabDoc, margins, 'portrait', undefined, undefined, 'A4', undefined, 1.27);
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/tab.odt', bytes);
|
||||
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/tabout /tmp/lo-rt/tab.odt', { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
const res = importOdt(new Uint8Array(readFileSync('/tmp/lo-rt/tabout/tab.odt')));
|
||||
check('LO tab: no warnings', res.warnings.length === 0, res.warnings);
|
||||
check('LO tab: interval ≈1.27cm', Math.abs((res.tabIntervalCm ?? 0) - 1.27) < 0.01, res.tabIntervalCm);
|
||||
const stops = (res.content.content ?? [])[0]?.attrs?.tabStops;
|
||||
check('LO tab: stops + leaders survive', stops === '6l.;12r_', stops);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// The document's default tab interval: Word's w:defaultTabStop, ODF's
|
||||
// style:tab-stop-distance on the paragraph default-style.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { DEFAULT_TAB_INTERVAL_CM, clampTabInterval } from '../../src/lib/storage/tabInterval';
|
||||
|
||||
const doc: any = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a\tb' }] }] };
|
||||
const opts = [undefined, 'portrait', undefined, undefined, 'A4', undefined] as const;
|
||||
const odt = (cm?: number) => buildOdt(doc, ...(opts as any), cm);
|
||||
const docx = (cm?: number) => buildDocx(doc, ...(opts as any), cm);
|
||||
|
||||
describe('default tab interval', () => {
|
||||
it('rides an ODF default-style and comes back', async () => {
|
||||
const back = await importOdt(await odt(1.27));
|
||||
expect(back.tabIntervalCm).toBeCloseTo(1.27, 3);
|
||||
});
|
||||
|
||||
it('leaves LibreOffice’s own interval undeclared', async () => {
|
||||
const styles = strFromU8(unzipSync(await odt(DEFAULT_TAB_INTERVAL_CM))['styles.xml']);
|
||||
expect(styles).not.toContain('style:tab-stop-distance');
|
||||
expect((await importOdt(await odt(DEFAULT_TAB_INTERVAL_CM))).tabIntervalCm).toBe(null);
|
||||
});
|
||||
|
||||
it('rides a DOCX setting and comes back', async () => {
|
||||
// Word stores twips, so 1.27cm = 720tw is exact and 1.25cm rounds to 709tw.
|
||||
expect(importDocx(await docx(1.27)).tabIntervalCm).toBeCloseTo(1.27, 2);
|
||||
expect(importDocx(await docx(DEFAULT_TAB_INTERVAL_CM)).tabIntervalCm).toBeCloseTo(1.25, 2);
|
||||
});
|
||||
|
||||
it('clamps a nonsense value to something renderable', () => {
|
||||
expect(clampTabInterval(0)).toBe(0.05);
|
||||
expect(clampTabInterval(999)).toBe(10);
|
||||
expect(clampTabInterval(NaN)).toBe(DEFAULT_TAB_INTERVAL_CM);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// A tab stop's leader: the character it repeats across its gap (Word w:tab w:leader,
|
||||
// ODF style:leader-text).
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildOdt } from '../../src/lib/export/odt';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { parseTabStops, formatTabStops } from '../../src/lib/editor/extensions/tabStops';
|
||||
|
||||
const doc: any = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', attrs: { tabStops: '6l.;12r_' }, content: [{ type: 'text', text: 'a\tb\tc' }] },
|
||||
{ type: 'paragraph', attrs: { tabStops: '4c' }, content: [{ type: 'text', text: 'x\ty' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const stopsOfFirst = (d: any) => d.content?.[0]?.attrs?.tabStops ?? null;
|
||||
|
||||
describe('tab leaders', () => {
|
||||
it('round-trips through the attr form', () => {
|
||||
expect(parseTabStops('6l.;12r_')).toEqual([
|
||||
{ pos: 6, align: 'left', leader: '.' },
|
||||
{ pos: 12, align: 'right', leader: '_' },
|
||||
]);
|
||||
expect(formatTabStops(parseTabStops('6l.;12r_'))).toBe('6l.;12r_');
|
||||
// A character we can't render is dropped rather than approximated.
|
||||
expect(parseTabStops('6l~')[0].leader).toBe(null);
|
||||
});
|
||||
|
||||
it('rides ODF style:leader-text and comes back', async () => {
|
||||
const bytes = await buildOdt(doc);
|
||||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||||
expect(content).toContain('style:leader-style="dotted" style:leader-text="."');
|
||||
expect(content).toContain('style:leader-style="solid" style:leader-text="_"');
|
||||
// The stop without one stays bare.
|
||||
expect(content).toContain('<style:tab-stop style:position="4cm" style:type="center"/>');
|
||||
expect(stopsOfFirst(importOdt(bytes).content)).toBe('6l.;12r_');
|
||||
});
|
||||
|
||||
it('keeps two stops at one position apart', async () => {
|
||||
// odf-kit mints one automatic style per option set, so without the leader riding
|
||||
// style:type both paragraphs would share a style — and with it one leader.
|
||||
const mixed: any = { type: 'doc', content: [
|
||||
{ type: 'paragraph', attrs: { tabStops: '6l.' }, content: [{ type: 'text', text: 'a\tb' }] },
|
||||
{ type: 'paragraph', attrs: { tabStops: '6l' }, content: [{ type: 'text', text: 'c\td' }] },
|
||||
] };
|
||||
const content = strFromU8(unzipSync(await buildOdt(mixed))['content.xml']);
|
||||
expect(content).toContain('<style:tab-stop style:position="6cm" style:type="left" style:leader-style="dotted" style:leader-text="."/>');
|
||||
expect(content).toContain('<style:tab-stop style:position="6cm" style:type="left"/>');
|
||||
});
|
||||
|
||||
it('rides a DOCX w:leader and comes back', async () => {
|
||||
const bytes = await buildDocx(doc);
|
||||
expect(strFromU8(unzipSync(bytes)['word/document.xml'])).toContain('w:leader="dot"');
|
||||
expect(stopsOfFirst(importDocx(bytes).content)).toBe('6l.;12r_');
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,9 @@ const stopsOf = (d: N, i: number) => d.content[i].attrs?.tabStops ?? null;
|
||||
describe('tab stops', () => {
|
||||
it('parses and formats the canonical attr form', () => {
|
||||
expect(parseTabStops('6c;12r;16d')).toEqual([
|
||||
{ pos: 6, align: 'center' }, { pos: 12, align: 'right' }, { pos: 16, align: 'decimal' },
|
||||
{ pos: 6, align: 'center', leader: null },
|
||||
{ pos: 12, align: 'right', leader: null },
|
||||
{ pos: 16, align: 'decimal', leader: null },
|
||||
]);
|
||||
// Sorted, deduped by position, rounded to 0.01cm; empty becomes null.
|
||||
expect(formatTabStops([{ pos: 12, align: 'right' }, { pos: 1.004, align: 'left' }])).toBe('1l;12r');
|
||||
|
||||
@@ -21,5 +21,8 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
globals: false,
|
||||
// A build+re-import leg runs ~10s when the whole suite competes for the CPU,
|
||||
// well past vitest's 5s default.
|
||||
testTimeout: 60000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user