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
+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. */
|
||||
|
||||
Reference in new issue
Block a user