fix: pdf image list export
This commit is contained in:
1 parent
a0d7ac8be3
commit
798af26456
1 file changed
+70
@@ -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 = ['•', '◦', '▪']; // <ul> 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
|
||||
// <ul>, or the formatted ordinal (orderedListTypes) for an <ol>.
|
||||
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<HTMLLIElement>('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<void> {
|
||||
const paper = opts.source.closest('.paper') as HTMLElement | null;
|
||||
@@ -134,6 +203,7 @@ export async function exportPdf(opts: PdfOptions): Promise<void> {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user