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:
Steffen Becker committed 2026-08-08 14:40:26 +00:00
1 parent 6accc7268f
commit ea39dde58b
20 files changed
+319 -39

No files matched your search

+13 -3
View File
@@ -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}
+5 -2
View File
@@ -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;
+2 -1
View File
@@ -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();
+25 -12
View File
@@ -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
+2 -1
View File
@@ -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`).
+9 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+7 -1
View File
@@ -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;
}
+3
View File
@@ -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,
+17 -4
View File
@@ -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;
}
+2
View File
@@ -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`).
+28
View File
@@ -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
View File
@@ -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. */