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.
This commit is contained in:
Steffen Becker committed 2026-08-24 16:27:27 +00:00
1 parent 0753caa5a9
commit a5c072db23
16 files changed
+265 -16

No files matched your search

+12 -5
View File
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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<typeof buildOdt>[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}
+6
View File
@@ -199,6 +199,12 @@ per page in unscaled document px. The watermark is an `<svg><text textLength>` 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
+7 -1
View File
@@ -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}
<PageSheetLayer {pageBoxes} />
<PageDecorLayer decor={pageDecor} {pageBoxes} {pageMargins} />
{#if foldMarks}
<FoldMarkLayer {pageBoxes} />
{/if}
{#if lineNumbering.on}
<LineNumberLayer {editor} {tick} {lineNumbering} {pageBoxes} {pageMargins} />
{/if}
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { PX_PER_CM } from '../storage/pageMargins';
import { FOLD_MARK_MM, PUNCH_MARK_MM, MARK_START_MM, FOLD_MARK_LEN_MM, PUNCH_MARK_LEN_MM } from '../storage/foldMarks';
// Fold + punch marks in the left margin of every page, positioned from the page's
// own corner in unscaled document px (the zoom transform on .paper covers it).
let { pageBoxes }: {
pageBoxes: { top: number; left: number; height: number; width: number }[];
} = $props();
const mmToPx = (mm: number) => (mm / 10) * PX_PER_CM;
const marks = [
...FOLD_MARK_MM.map((mm) => ({ mm, len: FOLD_MARK_LEN_MM })),
{ mm: PUNCH_MARK_MM, len: PUNCH_MARK_LEN_MM },
];
</script>
<div class="fold-mark-layer" aria-hidden="true">
{#each pageBoxes as box}
{#each marks as m}
<span
class="fold-mark"
style="top: {box.top + mmToPx(m.mm)}px; left: {box.left + mmToPx(MARK_START_MM)}px; width: {mmToPx(m.len)}px;"
></span>
{/each}
{/each}
</div>
<style>
/* A sibling of the other layers inside the scaled .paper; the marks are the sheet's
own ink, so they keep the fixed page-text color through every theme. */
.fold-mark-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.fold-mark {
position: absolute;
border-top: 1px solid var(--color-page-text);
}
</style>
+3 -1
View File
@@ -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'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} {onAutoText} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:extraHfSections bind:hyphenate bind:pageNumbering bind:pageDecor bind:lineNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:extraHfSections bind:hyphenate bind:pageNumbering bind:pageDecor bind:lineNumbering bind:foldMarks onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
+1
View File
@@ -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'],
@@ -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)}
/>
<RibbonButton
variant="small"
icon="foldMarks"
label={t().ribbon.foldMarks}
title={t().ribbon.foldMarksHint}
active={foldMarks}
onclick={() => (foldMarks = !foldMarks)}
/>
</div>
</RibbonGroup>
+40 -6
View File
@@ -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) =>
`<v:line id="${id}" o:allowincell="f"` +
` style="position:absolute;mso-position-horizontal-relative:page;mso-position-vertical-relative:page;mso-wrap-style:none"` +
` from="${pt(MARK_START_MM)}pt,${pt(mm)}pt" to="${pt(MARK_START_MM + lenMm)}pt,${pt(mm)}pt"` +
` strokecolor="black" strokeweight=".72pt"><w10:wrap type="none"/></v:line>`;
const shapes = '<w:pict>'
+ 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) + '</w:pict>';
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:p\b[^>]*\/>|(<w:p\b[^>]*>(?:<w:pPr>.*?<\/w:pPr>)?)/,
(m, opening: string | undefined) =>
opening ? `${opening}<w:r>${shapes}</w:r>` : `<w:p><w:r>${shapes}</w:r></w:p>`));
changed = true;
}
if (!changed) return bytes;
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
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<Uint8Array> {
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));
}
+46 -2
View File
@@ -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 <draw:line>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 =
`<style:style style:name="FoldMark" style:family="graphic"><style:graphic-properties` +
` draw:stroke="solid" svg:stroke-color="#000000" svg:stroke-width="0.026cm" draw:fill="none"` +
` style:run-through="background" style:wrap="run-through" style:number-wrapped-paragraphs="no-limit"` +
` style:vertical-pos="from-top" style:vertical-rel="page"` +
` style:horizontal-pos="from-left" style:horizontal-rel="page" style:flow-with-text="false"/></style:style>`;
const cm = (mm: number) => Math.round(mm * 10) / 100;
const line = (name: string, mm: number, lenMm: number) =>
`<draw:line text:anchor-type="paragraph" draw:z-index="1" draw:name="${name}" draw:style-name="FoldMark"` +
` svg:x1="${cm(MARK_START_MM)}cm" svg:y1="${cm(mm)}cm" svg:x2="${cm(MARK_START_MM + lenMm)}cm" svg:y2="${cm(mm)}cm"/>`;
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(/<office:document-styles\b/, '<office:document-styles xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"');
out = injectAutomaticStyles(out, minted);
if (out.includes('<style:header>')) {
out = out.replace(/<style:header>(\s*<text:p[^>]*>)?/, (m) =>
m.includes('<text:p') ? `${m}${shapes}` : `${m}<text:p text:style-name="Header">${shapes}</text:p>`);
} else {
const zone = `<style:header><text:p text:style-name="Header">${shapes}</text:p></style:header>`;
out = out.replace(/<style:header-style\s*\/>/, '<style:header-style><style:header-footer-properties fo:min-height="0cm" fo:margin-bottom="0cm"/></style:header-style>');
out = /<style:master-page\b[^>]*\/>/.test(out)
? out.replace(/(<style:master-page\b[^>]*)\/>/, `$1>${zone}</style:master-page>`)
: out.replace(/(<style:master-page\b[^>]*>)/, `$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<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, 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<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).
@@ -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);
+2
View File
@@ -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',
+2
View File
@@ -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',
+11
View File
@@ -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<s
});
}
// The export's named fold-mark lines, in whichever header part carries them.
function docxFoldMarks(files: Record<string, Uint8Array>): 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<string, Uint8Array>): unknown {
+12 -1
View File
@@ -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) },
+1
View File
@@ -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` `<draw:custom-shape>` 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 `<text:linenumbering-configuration>` in `office:styles`, Word a `<w:lnNumType>` 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 `<draw:line>`s, DOCX as VML `<v:line>`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.
+30
View File
@@ -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 <draw:line>/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');
}
+40
View File
@@ -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);
});
});