diff --git a/src/lib/export/pdf.ts b/src/lib/export/pdf.ts
index bcaece3..80b2361 100644
--- a/src/lib/export/pdf.ts
+++ b/src/lib/export/pdf.ts
@@ -8,6 +8,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { extensions } from '../editor/extensions';
import { columnPercents } from '../editor/tableView';
+import { orderedTypeDef } from '../editor/orderedListTypes';
import { deriveFilename } from './odt';
import globalCss from '../../styles/global.css?inline';
import editorCss from '../../styles/editor.css?inline';
@@ -120,6 +121,74 @@ function collectRuns(root: HTMLElement): Run[] {
return runs;
}
+const BULLET_CHARS = ['•', '◦', '▪']; //
nesting cycle (disc/circle/square)
+
+function toRoman(n: number): string {
+ const map: [number, string][] = [[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i']];
+ let s = '';
+ for (const [v, sym] of map) while (n >= v) { s += sym; n -= v; }
+ return s;
+}
+
+function toAlpha(n: number): string {
+ let s = '';
+ while (n > 0) { n--; s = String.fromCharCode(97 + (n % 26)) + s; n = Math.floor(n / 26); }
+ return s;
+}
+
+// The marker text the browser renders for a list item: the nesting-depth bullet for a
+// , or the formatted ordinal (orderedListTypes) for an .
+function listMarkerGlyph(li: HTMLElement, root: HTMLElement): string {
+ const parent = li.parentElement;
+ if (!parent) return '';
+ if (parent.tagName === 'OL') {
+ const items = Array.from(parent.children).filter((c) => c.tagName === 'LI');
+ const n = parseInt(parent.getAttribute('start') ?? '1', 10) + items.indexOf(li);
+ const def = orderedTypeDef(parent.getAttribute('data-list-style'));
+ const body = def.numFormat === 'a' ? toAlpha(n)
+ : def.numFormat === 'A' ? toAlpha(n).toUpperCase()
+ : def.numFormat === 'i' ? toRoman(n)
+ : def.numFormat === 'I' ? toRoman(n).toUpperCase()
+ : String(n);
+ return body + def.numSuffix;
+ }
+ let depth = 0;
+ for (let e: HTMLElement | null = li.parentElement; e && e !== root; e = e.parentElement) {
+ if (e.tagName === 'UL' || e.tagName === 'OL') depth++;
+ }
+ return BULLET_CHARS[(depth - 1 + BULLET_CHARS.length) % BULLET_CHARS.length];
+}
+
+// html2canvas paints list markers at the item's content-box left edge, ignoring floats, so
+// a floating image that pushes an item's first line right strands the marker on the far side
+// of the image. Re-hang such markers beside the shifted line (matching the editor's render).
+function relocateFloatPushedMarkers(root: HTMLElement): void {
+ for (const li of Array.from(root.querySelectorAll('li'))) {
+ const cs = getComputedStyle(li);
+ if (cs.listStyleType === 'none' || cs.textAlign === 'center' || cs.textAlign === 'right') continue;
+ const p = li.querySelector(':scope > p');
+ if (!p) continue;
+ const range = document.createRange();
+ range.selectNodeContents(p);
+ const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0 && r.height > 0);
+ if (!rects.length) continue;
+ const liRect = li.getBoundingClientRect();
+ const contentLeft = liRect.left + parseFloat(cs.paddingLeft) + parseFloat(cs.borderLeftWidth);
+ // First line sits in the normal marker column → html2canvas already paints it right.
+ if (rects[0].left <= contentLeft + 6) continue;
+ const glyph = listMarkerGlyph(li, root);
+ if (!glyph) continue;
+ li.style.listStyleType = 'none';
+ const slot = document.createElement('span');
+ slot.style.cssText = 'display:inline-block;width:0;overflow:visible;vertical-align:baseline';
+ const label = document.createElement('span');
+ label.style.cssText = `display:inline-block;white-space:pre;transform:translateX(-100%);padding-right:0.4em;color:${cs.color}`;
+ label.textContent = glyph;
+ slot.appendChild(label);
+ p.insertBefore(slot, p.firstChild);
+ }
+}
+
// Render the document to A4 pages (raster + invisible text) and download the PDF.
export async function exportPdf(opts: PdfOptions): Promise {
const paper = opts.source.closest('.paper') as HTMLElement | null;
@@ -134,6 +203,7 @@ export async function exportPdf(opts: PdfOptions): Promise {
try {
await document.fonts.ready;
+ relocateFloatPushedMarkers(clone);
const pages = Math.max(1, opts.numPages ?? Math.round((clone.offsetHeight + PAGE_GAP) / cycle));
const runs = collectRuns(clone);