From a5c072db230ce2cae144f98532a1c5b325ef05e2 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 24 Aug 2026 16:27:27 +0000 Subject: [PATCH] feat: fold and punch marks (Falzmarken, DIN 5008) edentext-fold-marks flag: FoldMarkLayer draws the 87/192mm folds and the 148.5mm punch mark per page inside .paper, so print and PDF include them. Toggled in the Layout tab. Exported as named EdenFoldMark* lines in the master page's header (ODF page-relative draw:line, DOCX VML v:line); both importers read the names back into the flag. Roundtrip Leg 32. --- src/App.svelte | 17 +++++-- src/lib/components/CLAUDE.md | 6 +++ src/lib/components/Editor.svelte | 8 +++- src/lib/components/FoldMarkLayer.svelte | 42 ++++++++++++++++ src/lib/components/ribbon/Ribbon.svelte | 4 +- src/lib/components/ribbon/icons.ts | 1 + .../components/ribbon/tabs/LayoutTab.svelte | 10 ++++ src/lib/export/docx.ts | 46 +++++++++++++++--- src/lib/export/odt.ts | 48 ++++++++++++++++++- src/lib/i18n/locales/de.ts | 2 + src/lib/i18n/locales/en.ts | 2 + src/lib/import/docx.ts | 11 +++++ src/lib/import/odt.ts | 13 ++++- src/lib/storage/CLAUDE.md | 1 + src/lib/storage/foldMarks.ts | 30 ++++++++++++ tests/roundtrip.test.ts | 40 ++++++++++++++++ 16 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/FoldMarkLayer.svelte create mode 100644 src/lib/storage/foldMarks.ts diff --git a/src/App.svelte b/src/App.svelte index 5aeb4b5..b3b34dd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -44,6 +44,7 @@ import { loadPageNumbering, savePageNumbering, DEFAULT_PAGE_NUMBERING, type PageNumbering } from './lib/storage/pageNumbering'; import { loadPageDecor, savePageDecor, EMPTY_PAGE_DECOR, type PageDecor } from './lib/storage/pageDecor'; import { loadLineNumbering, saveLineNumbering, DEFAULT_LINE_NUMBERING, type LineNumbering } from './lib/storage/lineNumbering'; + import { loadFoldMarks, saveFoldMarks } from './lib/storage/foldMarks'; import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage'; import { setTableLanguage } from './lib/storage/tableOptions.svelte'; import { spellController } from './lib/spell/controller'; @@ -182,6 +183,7 @@ let pageNumbering: PageNumbering = $state(loadPageNumbering()); let pageDecor: PageDecor = $state(loadPageDecor()); let lineNumbering: LineNumbering = $state(loadLineNumbering()); + let foldMarks = $state(loadFoldMarks()); let autoCorrectOpen = $state(false); let autoTextOpen = $state(false); let thesaurusOpen = $state(false); @@ -266,6 +268,7 @@ savePageNumbering(pageNumbering); savePageDecor(pageDecor); saveLineNumbering(lineNumbering); + saveFoldMarks(foldMarks); }); $effect(() => { @@ -560,6 +563,7 @@ pageNumbering = { ...DEFAULT_PAGE_NUMBERING }; pageDecor = { ...EMPTY_PAGE_DECOR }; lineNumbering = { ...DEFAULT_LINE_NUMBERING }; + foldMarks = false; // Recording belongs to the document, so a new one starts off, as it does in both. setRecordChanges(false); docProps = { ...EMPTY_DOC_PROPERTIES }; @@ -639,6 +643,7 @@ pageNumbering = result.pageNumbering; pageDecor = result.decor; lineNumbering = result.lineNumbering; + foldMarks = result.foldMarks === true; // The file says whether it goes on recording; ours is not the setting that counts. setRecordChanges(result.recordChanges); // Adopt the document's spell-check language (the $effect switches the @@ -773,12 +778,12 @@ // reference word processors — not silently rewritten to .odt under its old name. if (documentFormat === 'docx') { const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks); fileHandle = await saveDocx(bytes, suggestedFilenameDocx(json), fileHandle); recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilenameDocx(json), fileHandle); return; } - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); } catch (err) { @@ -795,7 +800,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); documentFormat = 'odt'; // Save As is odt-only, so a docx-opened document switches format here. recentFiles = await rememberRecentFile(fileHandle?.name ?? suggestedFilename(json), fileHandle); @@ -815,7 +820,7 @@ const json = editor.getJSON() as Parameters[0]; try { await saveAsTemplate(async (kind) => { - const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()] as const; + const args = [pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks] as const; const { odtToOtt, docxToDotx } = await import('./lib/export/template'); if (kind === 'dotx') { const { buildDocx } = await import('./lib/export/docx'); @@ -868,7 +873,7 @@ try { const json = editor.getJSON() as Parameters[0]; const { buildDocx } = await import('./lib/export/docx'); - const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges()); + const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings(), docProps, hyphenate, pageNumbering, pageDecor, lineNumbering, recordChanges(), foldMarks); await saveAsDocx(bytes, suggestedFilenameDocx(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -1100,6 +1105,7 @@ bind:pageNumbering bind:pageDecor bind:lineNumbering + bind:foldMarks {hfActive} onManageStyles={openStyleManager} onManageTableStyles={() => openStyleManager('table')} @@ -1434,6 +1440,7 @@ {pageNumbering} {pageDecor} {lineNumbering} + {foldMarks} bind:extraHfSections {zoom} onZoom={setZoom} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index ef6ddd9..6d0fa69 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -199,6 +199,12 @@ per page in unscaled document px. The watermark is an `` b fontwork shape **stretches** its text to its box instead of setting it at a size — a font size alone makes a short word far too small and a long one overflow the page. +## Fold marks (`FoldMarkLayer.svelte`, `storage/foldMarks.ts`) + +Fold + punch marks at the DIN 5008 positions, drawn per page in the left margin from +`pageBoxes`. Inside the scaled `.paper`, so print and the PDF raster include them; the +Layout tab's toggle flips the flag. + ## Line numbering (`LineNumberLayer.svelte`, `storage/lineNumbering.ts`) One number per rendered line in the left margin. There is no CSS line box to read, so diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 580acfc..c87666e 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -25,6 +25,7 @@ import PageDecorLayer from './PageDecorLayer.svelte'; import PageSheetLayer from './PageSheetLayer.svelte'; import LineNumberLayer from './LineNumberLayer.svelte'; +import FoldMarkLayer from './FoldMarkLayer.svelte'; import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering'; import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; import Ruler from './Ruler.svelte'; @@ -54,7 +55,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, splitView = false, pageColumns = 1, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', - pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, pageRtl = false, hyphenate = false, documentLanguage = 'en', pageNumbering = DEFAULT_PAGE_NUMBERING, pageDecor = EMPTY_PAGE_DECOR, lineNumbering = DEFAULT_LINE_NUMBERING, foldMarks = false, 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, @@ -80,6 +81,8 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; pageDecor?: PageDecor; /** Numbers in the left margin, one per rendered line. */ lineNumbering?: LineNumbering; + /** Fold + punch marks in the left margin (DIN 5008 letter sheets). */ + foldMarks?: boolean; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -1450,6 +1453,9 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor'; {/if} + {#if foldMarks} + + {/if} {#if lineNumbering.on} {/if} diff --git a/src/lib/components/FoldMarkLayer.svelte b/src/lib/components/FoldMarkLayer.svelte new file mode 100644 index 0000000..bdf90be --- /dev/null +++ b/src/lib/components/FoldMarkLayer.svelte @@ -0,0 +1,42 @@ + + + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index b88bad5..28cb5af 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -56,6 +56,7 @@ pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), pageDecor = $bindable(EMPTY_PAGE_DECOR), lineNumbering = $bindable(DEFAULT_LINE_NUMBERING), + foldMarks = $bindable(false), hfActive = null, onManageStyles, onManageTableStyles, @@ -94,6 +95,7 @@ pageNumbering?: PageNumbering; pageDecor?: PageDecor; lineNumbering?: LineNumbering; + foldMarks?: boolean; hfActive?: HfZone | null; onManageStyles?: (family: StyleFamily) => void; onManageTableStyles?: (family: StyleFamily) => void; @@ -350,7 +352,7 @@ {:else if tab === 'insert'} {:else if tab === 'layout'} - (paragraphDialogOpen = true)} /> + (paragraphDialogOpen = true)} /> {:else if tab === 'references'} {:else if tab === 'review'} diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 5556d0e..54220a0 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -89,6 +89,7 @@ export const STROKED = { trackChanges: ['M2 4h12M2 8h7', 'M9.5 13.5 14 9l-1.6-1.6L7.9 12z', 'M7.9 12 7.4 14.1l2.1-.6'], close: ['M3.5 3.5 12.5 12.5M12.5 3.5 3.5 12.5'], lineNumbers: ['M6 3h8.5M6 6.5h8.5M6 10h8.5M6 13.5h8.5', 'M2 2.2h1.1v3.3M1.6 5.5h1.9', 'M1.5 9.6c.2-.4.6-.5 1-.4.5.1.6.6.3 1L1.6 11.6h1.9'], + foldMarks: ['M4.5 2h9v12h-9z', 'M1.5 5h2M1.5 8.2h2.8M1.5 11.4h2'], wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'], // An open book, the glyph both word processors give the thesaurus. thesaurus: ['M8 4.5C6.5 3.4 4.4 3.1 2.2 3.3v9c2.2-.2 4.3.1 5.8 1.2 1.5-1.1 3.6-1.4 5.8-1.2v-9c-2.2-.2-4.3.1-5.8 1.2z', 'M8 4.5v9'], diff --git a/src/lib/components/ribbon/tabs/LayoutTab.svelte b/src/lib/components/ribbon/tabs/LayoutTab.svelte index 2a82961..a41eb40 100644 --- a/src/lib/components/ribbon/tabs/LayoutTab.svelte +++ b/src/lib/components/ribbon/tabs/LayoutTab.svelte @@ -27,6 +27,7 @@ pageNumbering = $bindable(DEFAULT_PAGE_NUMBERING), pageDecor = $bindable(EMPTY_PAGE_DECOR), lineNumbering = $bindable(DEFAULT_LINE_NUMBERING), + foldMarks = $bindable(false), onParagraphDialog, }: { editor: Editor | null; @@ -40,6 +41,7 @@ pageNumbering?: PageNumbering; pageDecor?: PageDecor; lineNumbering?: LineNumbering; + foldMarks?: boolean; onParagraphDialog?: () => void; } = $props(); @@ -365,6 +367,14 @@ active={!!(pageDecor.background || pageDecor.border || pageDecor.watermark)} onclick={() => (decorOpen = true)} /> + (foldMarks = !foldMarks)} + /> diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 15a6ad6..649f8f4 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -40,6 +40,7 @@ import { normalizeColor, GENERATOR, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson import { EMPTY_DOC_PROPERTIES, type DocProperties } from '../storage/docProperties'; import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; import { EMPTY_PAGE_DECOR, isEmptyPageDecor, type PageDecor, type Watermark } from '../storage/pageDecor'; +import { FOLD_MARK_MM, PUNCH_MARK_MM, MARK_START_MM, FOLD_MARK_LEN_MM, PUNCH_MARK_LEN_MM, FOLD_MARK_NAME } from '../storage/foldMarks'; import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering'; // The five page-number formats both word processors offer → Word's own names. @@ -1515,6 +1516,37 @@ function applyPageDecorDocx(bytes: Uint8Array, decor: PageDecor, widthPt: number return zipSync(out); } +// Post-pack pass: fold + punch marks as named VML lines in every header part — the one +// place that repeats on every page — positioned from the page corner like the watermark. +function applyFoldMarksDocx(bytes: Uint8Array, on: boolean): Uint8Array { + if (!on) return bytes; + const files = unzipSync(bytes); + const pt = (mm: number) => Math.round((mm / 25.4) * 72 * 100) / 100; + const line = (id: string, mm: number, lenMm: number) => + ``; + const shapes = '' + + FOLD_MARK_MM.map((mm, i) => line(`${FOLD_MARK_NAME}${i + 1}`, mm, FOLD_MARK_LEN_MM)).join('') + + line(`${FOLD_MARK_NAME}Punch`, PUNCH_MARK_MM, PUNCH_MARK_LEN_MM) + ''; + let changed = false; + for (const path of Object.keys(files)) { + if (!/^word\/header\d*\.xml$/.test(path)) continue; + const xml = strFromU8(files[path]); + if (xml.includes(FOLD_MARK_NAME)) continue; + // Inside a paragraph, like the watermark: as a child of w:hdr the pict is dropped. + files[path] = strToU8(xml.replace(/]*\/>|(]*>(?:.*?<\/w:pPr>)?)/, + (m, opening: string | undefined) => + opening ? `${opening}${shapes}` : `${shapes}`)); + changed = true; + } + if (!changed) return bytes; + const out: Record = {}; + for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }]; + return zipSync(out); +} + const WATERMARK_NAME = 'PowerPlusWaterMarkObject'; // The shape's aspect ratio, measured off LibreOffice's own watermark. const WATERMARK_RATIO = 4.487; @@ -2212,6 +2244,7 @@ export async function buildDocx( decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, + foldMarks = false, ): Promise { docLangTag = localeTag(language ? language.language : 'en'); exportSheet = styles; @@ -2303,12 +2336,12 @@ export async function buildDocx( const mkHeaders = (i: number) => { const s = setAt(i); const d = para(s.header), f = s.differentFirstPage ? para(s.headerFirst) : null, e = s.differentOddEven ? para(s.headerEven) : null; - // A watermark lives in a header part, so a document without one still needs the - // (empty) default header for applyPageDecorDocx to inject the shape into. - if (!d && !f && !e) return decor.watermark?.text ? { default: new Header({ children: [new Paragraph({})] }) } : undefined; + // A watermark (and the fold marks) lives in a header part, so a document without + // one still needs the (empty) default header the post-passes inject into. + if (!d && !f && !e) return decor.watermark?.text || foldMarks ? { default: new Header({ children: [new Paragraph({})] }) } : undefined; const h: { default?: Header; first?: Header; even?: Header } = {}; if (d) h.default = new Header({ children: [paragraphToDocx(d)] }); - else if (decor.watermark?.text) h.default = new Header({ children: [new Paragraph({})] }); + else if (decor.watermark?.text || foldMarks) h.default = new Header({ children: [new Paragraph({})] }); if (f) h.first = new Header({ children: [paragraphToDocx(f)] }); if (e) h.even = new Header({ children: [paragraphToDocx(e)] }); return h; @@ -2387,9 +2420,10 @@ export async function buildDocx( const withResolved = applyCommentsResolvedDocx(withNotes); const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withResolved) : withResolved; const bidi = applyNoHyphensDocx(rtl ? applyBidiDocx(mirrored) : mirrored); - if (isEmptyPageDecor(decor)) return bidi; + const marked = applyFoldMarksDocx(bidi, foldMarks); + if (isEmptyPageDecor(decor)) return marked; const dims = pageDimsCm(pageFormat, orientation); const pt = (cm: number) => (cm / 2.54) * 72; - return applyPageDecorDocx(bidi, decor, + return applyPageDecorDocx(marked, decor, pt(dims.w - margins.left - margins.right), pt(dims.h - margins.top - margins.bottom)); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 096d4a8..ed10e30 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -10,6 +10,7 @@ import { DEFAULT_NOTE_SETTINGS, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind import { EMPTY_DOC_PROPERTIES, keywordList, type DocProperties } from '../storage/docProperties'; import { DEFAULT_PAGE_NUMBERING, type PageNumbering } from '../storage/pageNumbering'; import { EMPTY_PAGE_DECOR, type PageDecor, type Watermark } from '../storage/pageDecor'; +import { FOLD_MARK_MM, PUNCH_MARK_MM, MARK_START_MM, FOLD_MARK_LEN_MM, PUNCH_MARK_LEN_MM, FOLD_MARK_NAME } from '../storage/foldMarks'; import { DEFAULT_LINE_NUMBERING, type LineNumbering } from '../storage/lineNumbering'; import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { @@ -2773,6 +2774,49 @@ function applyWatermarkOdf(odtBytes: Uint8Array, wm: Watermark | null): Uint8Arr return rezipOdt(files); } +// Fold + punch marks as named s in the master page's header — the one place +// that repeats on every page, where LibreOffice's own DIN letter templates keep them. +// Page-relative position (from-top/from-left against "page"), out of flow like the +// watermark; the importers read the EdenFoldMark names back into the flag. +function applyFoldMarksOdf(odtBytes: Uint8Array, on: boolean): Uint8Array { + if (!on) return odtBytes; + const files = unzipSync(odtBytes); + const stylesBytes = files['styles.xml']; + if (!stylesBytes) return odtBytes; + const styles = strFromU8(stylesBytes); + const minted = + ``; + const cm = (mm: number) => Math.round(mm * 10) / 100; + const line = (name: string, mm: number, lenMm: number) => + ``; + const shapes = FOLD_MARK_MM.map((mm, i) => line(`${FOLD_MARK_NAME}${i + 1}`, mm, FOLD_MARK_LEN_MM)).join('') + + line(`${FOLD_MARK_NAME}Punch`, PUNCH_MARK_MM, PUNCH_MARK_LEN_MM); + + // Same splice as the watermark: declare draw:, mint the style, ride the header — + // or give the master page a zero-height one. + let out = styles.includes('xmlns:draw=') + ? styles + : styles.replace(/')) { + out = out.replace(/(\s*]*>)?/, (m) => + m.includes('${shapes}`); + } else { + const zone = `${shapes}`; + out = out.replace(//, ''); + out = /]*\/>/.test(out) + ? out.replace(/(]*)\/>/, `$1>${zone}`) + : out.replace(/(]*>)/, `$1${zone}`); + } + files['styles.xml'] = strToU8(out); + return rezipOdt(files); +} + // The text width the page layout declares, in cm — the watermark spans it, as // LibreOffice's does. function lengthOfPageLayout(styles: string): number { @@ -4500,7 +4544,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(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false): Promise { +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, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS, props: DocProperties = EMPTY_DOC_PROPERTIES, hyphenate = false, pageNumbering: PageNumbering = DEFAULT_PAGE_NUMBERING, decor: PageDecor = EMPTY_PAGE_DECOR, lineNumbering: LineNumbering = DEFAULT_LINE_NUMBERING, recordChanges = false, foldMarks = false): Promise { // 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). @@ -4744,7 +4788,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU 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, tabIntervalCm, margins.mirrored === true, rtl, notesSettings, hyphenate, pageNumbering, decor, lineNumbering); const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); - const withWatermark = applyWatermarkOdf(withHf, decor.watermark); + const withWatermark = applyFoldMarksOdf(applyWatermarkOdf(withHf, decor.watermark), foldMarks); // 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. const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 1aa03b9..ccea8b0 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -849,6 +849,8 @@ const de: Messages = { pageNumberContinue: 'Vom vorherigen Abschnitt fortsetzen', lineNumbers: 'Zeilennummern', pageDecor: 'Seitengestaltung', + foldMarks: 'Falzmarken', + foldMarksHint: 'Falz- und Lochmarken am linken Rand (DIN 5008)', hyphenationHint: 'Automatisch trennen', debugDump: 'Dump', cellMargins: 'Zellenränder', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 1f6bf22..f3e4129 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -855,6 +855,8 @@ const en = { pageNumberContinue: 'Continue from the previous section', lineNumbers: 'Line numbers', pageDecor: 'Page design', + foldMarks: 'Fold marks', + foldMarksHint: 'Fold and punch marks in the left margin (DIN 5008)', hyphenationHint: 'Hyphenate automatically', debugDump: 'Dump', cellMargins: 'Cell margins', diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index e4db04f..12901d8 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -3,6 +3,7 @@ import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, readTabStops, togg import { lengthToPt, WATERMARK_NAME } from './styleResolver'; import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt'; import { PLACEHOLDER_SDT_TAG } from '../export/docx'; +import { FOLD_MARK_NAME } from '../storage/foldMarks'; import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { fitInlineImage, framePx } from '../editor/extensions/image'; @@ -263,6 +264,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = rtl: sectPrRtl(finalSectPr), decor: docxPageDecor(docDoc, finalSectPr, files), lineNumbering: docxLineNumbering(finalSectPr), + foldMarks: docxFoldMarks(files), hyphenate: docAutoHyphenation(files), recordChanges: docRecordsChanges(files), pageNumbering: docxPageNumbering(sectPr), @@ -2075,6 +2077,9 @@ function convertPict(pict: Element, ctx: Ctx): Node | null { const shape = Array.from(pict.children).find( (c) => c.namespaceURI === VML && ['shape', 'rect', 'oval', 'roundrect'].includes(c.localName), ); + // Fold-mark lines ride the flag (storage/foldMarks.ts), never the zone's content. + const vmlLine = Array.from(pict.children).find((c) => c.namespaceURI === VML && c.localName === 'line'); + if (vmlLine?.getAttribute('id')?.startsWith(FOLD_MARK_NAME)) return null; if (!shape) { ctx.warnings.add('Drawings were removed'); return null; } // The watermark rides the page decoration (storage/pageDecor.ts), not the header's // content, so it must not also arrive here as a shape. @@ -2561,6 +2566,12 @@ function docxPageDecor(docDoc: Document, sectPr: Element | null, files: Record): boolean { + return Object.keys(files).some((path) => + /^word\/header\d*\.xml$/.test(path) && strFromU8(files[path]).includes(FOLD_MARK_NAME)); +} + // The VML shape Word and LibreOffice both name PowerPlusWaterMarkObject, in whichever // header part carries it. Its text rides v:textpath, its angle the style's rotation. function docxWatermark(files: Record): unknown { diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 76209ff..a684049 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -35,6 +35,7 @@ import type { IndexKind } from '../editor/extensions/tableOfContents'; import { isBibType } from '../editor/extensions/bibliographyEntry'; import { citationStyleFromTemplate } from '../utils/citationStyle'; import type { PageDecor } from '../storage/pageDecor'; +import { FOLD_MARK_NAME } from '../storage/foldMarks'; import type { LineNumbering } from '../storage/lineNumbering'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; @@ -69,6 +70,8 @@ export interface OdtImportResult { // Page background, page border and watermark (storage/pageDecor.ts). decor: PageDecor; lineNumbering: LineNumbering; + // Fold + punch marks in the left margin (the export's named header lines). + foldMarks: boolean; // Automatic hyphenation (ODF fo:hyphenate on the base style, Word w:autoHyphenation). hyphenate: boolean; // Whether the document records revisions (ODF text:track-changes, Word w:trackRevisions). @@ -160,6 +163,8 @@ type Ctx = { // Footnotes/endnotes in anchor order: the file stores each note's text at its anchor, // the editor keeps them in one section at the document end (notes.ts). notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[]; styleName: string | null }[]; + // Set when a header carries the export's named fold-mark lines (storage/foldMarks.ts). + foldMarks: boolean; }; // Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format @@ -630,6 +635,11 @@ function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node // The watermark is not a drawing: it rides the page decoration instead // (storage/pageDecor.ts), so it must not also arrive as a shape in the header. if (e.getAttributeNS(NS.draw, 'name') === WATERMARK_NAME) return null; + // Fold marks ride the flag (storage/foldMarks.ts), not the document, same rule. + if (e.getAttributeNS(NS.draw, 'name')?.startsWith(FOLD_MARK_NAME)) { + ctx.foldMarks = true; + return null; + } if (e.localName === 'frame') { const textBoxEl = Array.from(e.children).find( c => c.namespaceURI === NS.draw && c.localName === 'text-box', @@ -739,7 +749,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const contentWidthCm = geo ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right : pageDimsCm('A4', 'portrait').w - 2 * 2.12; - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [] }; + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, pageRtl: geo?.rtl ?? false, masterPages: [], masterPageStarts: [], masterBlocks: new Map(), openBookmarks: new Set(), openComments: new Map(), revisions: odfRevisions(body), openInsertions: new Map(), notes: [], foldMarks: false }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm))); @@ -819,6 +829,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = rtl: geometry?.rtl ?? false, decor: resolver.pageDecor(), lineNumbering: resolver.lineNumbering(), + foldMarks: ctx.foldMarks, hyphenate: resolver.documentHyphenation(), recordChanges: odfRecordChanges(body), pageNumbering: { format: resolver.pageNumberFormat(), start: odfPageNumberStart(resolver, body) }, diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 6a89fcd..9e845c8 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -19,6 +19,7 @@ - **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`. - **`trackChanges.svelte.ts`** — whether the **document** records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). It travels with the file — ODF `text:track-changes` on the change registry, Word `w:trackRevisions` in settings.xml (both probed against soffice) — so an import adopts the file's flag and a new document starts off; only the open document's state lives under the key. A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks. - **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `` in `office:styles`, Word a `` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines. +- **`foldMarks.ts`** — fold + punch marks in the left margin (`edentext-fold-marks`, the key removed when off; neither word processor prints any by default). The DIN 5008 positions (87/192mm folds, 148.5mm punch) are this module's constants; `FoldMarkLayer.svelte` draws them per page. They export as named lines (`EdenFoldMark*`) in the master page's header — the one place that repeats on every page, where LibreOffice's own DIN templates keep them — ODF as page-relative ``s, DOCX as VML ``s; both importers read the names back into the flag and never into the zone's content. - **`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. diff --git a/src/lib/storage/foldMarks.ts b/src/lib/storage/foldMarks.ts new file mode 100644 index 0000000..1aa7a23 --- /dev/null +++ b/src/lib/storage/foldMarks.ts @@ -0,0 +1,30 @@ +// Fold and punch marks in the left margin (DIN 5008 letter sheets). Off by default — +// neither word processor prints any — so a fresh document writes nothing. They ride the +// master page's header on export, like the watermark, as named /VML lines. + +const KEY = 'edentext-fold-marks'; + +/** Distances from the top paper edge, in mm (DIN 5008: fold marks and punch mark). */ +export const FOLD_MARK_MM = [87, 192]; +export const PUNCH_MARK_MM = 148.5; + +/** Tick geometry, in mm from the left paper edge; the punch mark is the longer one. */ +export const MARK_START_MM = 5; +export const FOLD_MARK_LEN_MM = 4; +export const PUNCH_MARK_LEN_MM = 8; + +/** The export names its lines EdenFoldMark1/2/Punch; the importers read them back. */ +export const FOLD_MARK_NAME = 'EdenFoldMark'; + +export function loadFoldMarks(): boolean { + try { + return localStorage.getItem(KEY) === 'true'; + } catch { + return false; + } +} + +export function saveFoldMarks(on: boolean): void { + if (!on) localStorage.removeItem(KEY); + else localStorage.setItem(KEY, 'true'); +} diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 1f16d20..5f10899 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -16,6 +16,10 @@ import { importDocx } from '../src/lib/import/docx'; import { EMPTY_HF_SET } from '../src/lib/storage/headerFooter'; import { DEFAULT_NOTE_SETTINGS } from '../src/lib/storage/noteSettings'; import { DEFAULT_PAGE_NUMBERING } from '../src/lib/storage/pageNumbering'; +import { EMPTY_PAGE_DECOR } from '../src/lib/storage/pageDecor'; +import { DEFAULT_LINE_NUMBERING } from '../src/lib/storage/lineNumbering'; +import { EMPTY_DOC_PROPERTIES } from '../src/lib/storage/docProperties'; +import { FOLD_MARK_NAME } from '../src/lib/storage/foldMarks'; type N = any; @@ -2535,3 +2539,39 @@ describe('Leg 31: placeholder fields (ODT + DOCX)', () => { !fields.some((f) => (f.marks ?? []).some((m: N) => m.type === 'textStyle' && m.attrs?.color)), fields); }); }); + +describe('Leg 32: fold marks (ODT + DOCX)', () => { + const doc: N = { type: 'doc', content: [P(null, T('Brieftext'))] }; + const commonTail = [undefined, undefined, 'A4', builtinStyleSheet(), 1.25, 'add', false, + DEFAULT_NOTE_SETTINGS, EMPTY_DOC_PROPERTIES, false, DEFAULT_PAGE_NUMBERING, + EMPTY_PAGE_DECOR, DEFAULT_LINE_NUMBERING, false, true] as const; + + it('ODT: named header lines carry the flag both ways', async () => { + const bytes = await buildOdt(doc, margins, 'portrait', ...commonTail); + const styles = strFromU8(unzipSync(bytes)['styles.xml']); + check('three named lines in the header', (styles.match(new RegExp(FOLD_MARK_NAME, 'g')) ?? []).length === 3, styles.slice(0, 200)); + check('page-relative position', styles.includes('style:vertical-rel="page"')); + const res = importOdt(bytes); + check('flag comes back', res.foldMarks === true); + check('no shape leaks into the header zone', res.header === null, res.header); + check('no warning for the dropped lines', res.warnings.length === 0, res.warnings); + // Off exports nothing and imports off. + const plain = await buildOdt(doc, margins, 'portrait'); + check('off writes no lines', !strFromU8(unzipSync(plain)['styles.xml']).includes(FOLD_MARK_NAME)); + check('off imports off', importOdt(plain).foldMarks === false); + }); + + it('DOCX: named VML lines carry the flag both ways', async () => { + const bytes = await buildDocx(doc, margins, 'portrait', ...commonTail); + const files = unzipSync(bytes); + const headers = Object.keys(files).filter((p) => /^word\/header\d*\.xml$/.test(p)); + check('a header part exists for the lines', headers.length > 0, Object.keys(files)); + check('the lines ride a header', headers.some((p) => strFromU8(files[p]).includes(FOLD_MARK_NAME))); + const res = importDocx(bytes); + check('flag comes back', res.foldMarks === true); + check('no shape leaks into the header zone', res.header === null, res.header); + check('no warning for the dropped lines', res.warnings.length === 0, res.warnings); + const plain = await buildDocx(doc, margins, 'portrait'); + check('off imports off', importDocx(plain).foldMarks === false); + }); +});