Fix WYSIWYG pagination and remote images for 0.3.65

This commit is contained in:
Murat 2026-07-15 19:42:32 +08:00
parent de92873086
commit 756796c674
7 changed files with 293 additions and 80 deletions

106
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "mobile-pdf-exporter",
"name": "Mobile PDF Exporter",
"version": "0.3.64",
"version": "0.3.65",
"minAppVersion": "1.5.0",
"description": "One-click selectable preview PDF export for mobile and desktop.",
"author": "Murat / Codex",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-mobile-pdf-exporter",
"version": "0.3.64",
"version": "0.3.65",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-mobile-pdf-exporter",
"version": "0.3.64",
"version": "0.3.65",
"license": "MIT",
"dependencies": {
"@pdf-lib/fontkit": "^1.1.1",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-mobile-pdf-exporter",
"version": "0.3.64",
"version": "0.3.65",
"description": "One-click selectable preview PDF export plugin.",
"main": "main.js",
"scripts": {

View file

@ -528,6 +528,8 @@ const EXCALIDRAW_IMAGE_PDF_SUBJECT = "Image PDF exported from Obsidian Excalidra
const MAX_SVG_FRAGMENTS_PER_PAGE = 24;
const SVG_IMAGE_LOAD_TIMEOUT_MS = 1800;
const IMAGE_WAIT_TIMEOUT_MS = 2500;
const REMOTE_IMAGE_CORS_TIMEOUT_MS = 5000;
const REMOTE_IMAGE_REQUEST_TIMEOUT_MS = 6000;
const PREVIEW_RENDER_TIMEOUT_MS = 12000;
const EXCALIDRAW_IMAGE_RENDER_TIMEOUT_MS = 45000;
const EXCALIDRAW_IMAGE_LOAD_TIMEOUT_MS = 15000;
@ -1082,22 +1084,30 @@ export default class MobilePdfExporterPlugin extends Plugin {
);
const capturedActualTops = new Set<number>();
let appendedFinalWindows = false;
let appendedPreviewMissingWindows = false;
scrollEl.scrollLeft = 0;
for (let index = 0; index < scrollPositions.length; index += 1) {
throwIfExportCancelled(signal);
await settleLiveSurfaceAtScrollPosition(rootEl, scrollEl, scrollPositions[index], signal);
if (surface.mode === "preview" && index > 0) {
await waitForPreviewDomStable(rootEl, 750);
await settleLiveSurfaceAtScrollPosition(rootEl, scrollEl, scrollPositions[index], signal, previewRenderer);
if (surface.mode === "preview" && index > 0 && !previewRenderer) {
await waitForPreviewDomStable(rootEl, 360);
}
refreshLiveDrawingSurface(rootEl);
await nextAnimationFrame();
if (rootEl.querySelector("img")) {
if (previewRenderer) {
const connectedSections = getUncapturedConnectedPreviewSectionElements(
rootEl,
previewRenderer,
previewSectionCaptures
);
const waitedForImages = await waitForImagesInElements(connectedSections, 900);
if (waitedForImages) {
await settleLiveSurfaceAtScrollPosition(rootEl, scrollEl, scrollPositions[index], signal, previewRenderer);
}
} else if (rootEl.querySelector("img")) {
await waitForImages(rootEl, Math.min(IMAGE_WAIT_TIMEOUT_MS, 1100));
await settleLiveSurfaceAtScrollPosition(rootEl, scrollEl, scrollPositions[index], signal);
if (surface.mode === "preview") {
await waitForPreviewDomStable(rootEl, 750);
}
}
throwIfExportCancelled(signal);
@ -1171,7 +1181,8 @@ export default class MobilePdfExporterPlugin extends Plugin {
scrollPositions.push(...finalPositions);
}
if (previewRenderer && index === scrollPositions.length - 1) {
if (previewRenderer && !appendedPreviewMissingWindows && index === scrollPositions.length - 1) {
appendedPreviewMissingWindows = true;
const missingPositions = buildMissingLivePreviewSectionScrollPositions(
previewRenderer,
previewSectionCaptures,
@ -3028,6 +3039,24 @@ function captureConnectedLivePreviewSections(
}
}
function getUncapturedConnectedPreviewSectionElements(
rootEl: HTMLElement,
renderer: LivePreviewRenderer,
captures: Map<number, CapturedLivePreviewSection>
): HTMLElement[] {
return renderer.sections.flatMap((section, index) => {
const element = section.el;
if (
captures.has(index) ||
section.shown === false ||
section.rendered === false ||
!element?.isConnected ||
!rootEl.contains(element)
) return [];
return [element];
});
}
function buildMissingLivePreviewSectionScrollPositions(
renderer: LivePreviewRenderer,
captures: Map<number, CapturedLivePreviewSection>,
@ -3515,14 +3544,19 @@ async function settleLiveSurfaceAtScrollPosition(
rootEl: HTMLElement,
scrollEl: HTMLElement,
requestedTop: number,
signal?: AbortSignal
signal?: AbortSignal,
previewRenderer?: LivePreviewRenderer | null
): Promise<void> {
throwIfExportCancelled(signal);
const maximum = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
let expectedTop = clampNumber(requestedTop, 0, maximum, 0);
scrollEl.scrollTop = expectedTop;
scrollEl.dispatchEvent(new Event("scroll"));
await waitForLiveSurfaceSettled(rootEl, scrollEl, signal);
if (previewRenderer) {
await waitForLivePreviewRendererSettled(scrollEl, previewRenderer, signal);
} else {
await waitForLiveSurfaceSettled(rootEl, scrollEl, signal);
}
if (Math.abs(scrollEl.scrollTop - expectedTop) <= 1.5) return;
await waitForPromiseOrTimeout(new Promise<void>((resolve) => window.setTimeout(resolve, 40)), 80);
@ -3533,6 +3567,38 @@ async function settleLiveSurfaceAtScrollPosition(
await nextAnimationFrame(Math.min(180, FRAME_WAIT_TIMEOUT_MS));
}
async function waitForLivePreviewRendererSettled(
scrollEl: HTMLElement,
renderer: LivePreviewRenderer,
signal?: AbortSignal
): Promise<void> {
let previousSignature = "";
let stableFrames = 0;
for (let attempt = 0; attempt < 6; attempt += 1) {
throwIfExportCancelled(signal);
await nextAnimationFrame();
const sectionSignature = renderer.sections.map((section, index) => [
index,
section.el?.isConnected ? 1 : 0,
Math.round(getLivePreviewSectionHeight(section)),
section.rendered === false ? 0 : 1
].join(":")).join(";");
const signature = [
Math.round(scrollEl.scrollTop * 10) / 10,
Math.round(scrollEl.scrollHeight),
sectionSignature
].join("|");
if (signature === previousSignature) {
stableFrames += 1;
if (stableFrames >= 2) return;
} else {
previousSignature = signature;
stableFrames = 0;
}
}
}
async function waitForLiveSurfaceSettled(
rootEl: HTMLElement,
scrollEl: HTMLElement,
@ -4746,7 +4812,40 @@ function computePageBreaks(
}
if (breaks[breaks.length - 1] < contentHeightPx) breaks.push(contentHeightPx);
return breaks;
return enforceMaximumPageSpan(breaks, contentHeightPx, pageHeightPx, sortedBlocks);
}
function enforceMaximumPageSpan(
pageBreaks: number[],
contentHeightPx: number,
pageHeightPx: number,
sortedBlocks: KeepBlockFragment[]
): number[] {
const maximumSpan = Math.max(24, pageHeightPx);
const normalized = [0];
for (const rawTarget of pageBreaks.slice(1)) {
const target = clampNumber(rawTarget, 0, contentHeightPx, contentHeightPx);
let current = normalized[normalized.length - 1];
while (target - current > maximumSpan + 0.5) {
const idealBreak = current + maximumSpan;
const textSafeBreak = moveBreakOutsideTextLines(current, idealBreak, maximumSpan, sortedBlocks);
let nextBreak = Math.min(idealBreak, textSafeBreak);
if (nextBreak <= current + PAGE_BREAK_MIN_ADVANCE_PX) nextBreak = idealBreak;
normalized.push(nextBreak);
current = nextBreak;
}
const isContentEnd = target >= contentHeightPx - 0.5;
if (
target > current + 0.5 &&
(isContentEnd || target - current > PAGE_BREAK_MIN_ADVANCE_PX)
) normalized.push(target);
}
if (normalized[normalized.length - 1] < contentHeightPx - 0.5) normalized.push(contentHeightPx);
return normalized;
}
function moveBreakOutsideTextLines(
@ -5388,10 +5487,13 @@ async function drawCanvasImageLayer(
const slice = getMediaPageSlice(imageFragment, options);
if (!slice) continue;
const naturalHeight = Math.max(1, imageFragment.element.naturalHeight);
const sourceY = (slice.offsetTopPx / slice.fragmentHeightPx) * naturalHeight;
const sourceHeight = (slice.height / slice.fragmentHeightPx) * naturalHeight;
const sliceBytes = await imageSliceToPngBytes(imageFragment.element, sourceY, sourceHeight, "color");
const sliceBytes = await imageFragmentSliceToPngBytes(
imageFragment.element,
slice.offsetTopPx,
slice.height,
slice.fragmentHeightPx,
"color"
);
const sliceImage = await imageBytesToHtmlImage(sliceBytes);
context.drawImage(sliceImage, slice.x, slice.y, slice.width, slice.height);
} catch (error) {
@ -5455,6 +5557,13 @@ interface MediaPageSlice {
fragmentHeightPx: number;
}
interface RemoteCanvasImageCacheEntry {
source: string;
image: Promise<HTMLImageElement | null>;
}
const remoteCanvasImageCache = new WeakMap<HTMLImageElement, RemoteCanvasImageCacheEntry>();
function getMediaPageSlice(
fragment: { left: number; top: number; right: number; bottom: number },
options: {
@ -6144,6 +6253,72 @@ async function imageSliceToPngBytes(
return dataUrlToUint8Array(canvas.toDataURL("image/png"));
}
async function imageFragmentSliceToPngBytes(
image: HTMLImageElement,
offsetTopPx: number,
sliceHeightPx: number,
fragmentHeightPx: number,
colorMode: PdfColorMode = "color"
): Promise<Uint8Array> {
const sliceSource = (source: HTMLImageElement) => {
const sourceHeight = Math.max(1, source.naturalHeight || source.height);
const sourceY = (offsetTopPx / Math.max(1, fragmentHeightPx)) * sourceHeight;
const sourceHeightPx = (sliceHeightPx / Math.max(1, fragmentHeightPx)) * sourceHeight;
return imageSliceToPngBytes(source, sourceY, sourceHeightPx, colorMode);
};
try {
return await sliceSource(image);
} catch (directError) {
const remoteImage = await loadRemoteImageForCanvas(image);
if (!remoteImage) throw directError;
return sliceSource(remoteImage);
}
}
async function loadRemoteImageForCanvas(image: HTMLImageElement): Promise<HTMLImageElement | null> {
const source = String(image.currentSrc || image.src || "").trim();
if (!/^https?:\/\//iu.test(source)) return null;
const cached = remoteCanvasImageCache.get(image);
if (cached?.source === source) return cached.image;
const imagePromise = (async () => {
try {
try {
return await loadImage(source, REMOTE_IMAGE_CORS_TIMEOUT_MS, "anonymous");
} catch {
// Some hosts omit CORS headers even though Obsidian can request the image directly.
}
const response = await waitForPromiseOrTimeout(
requestUrl({ url: source, method: "GET" }),
REMOTE_IMAGE_REQUEST_TIMEOUT_MS
);
if (!response) throw new Error("Remote image download timed out.");
if (response.status < 200 || response.status >= 300 || response.arrayBuffer.byteLength < 1) {
throw new Error(`Remote image download failed with HTTP ${response.status}.`);
}
const contentType = response.headers?.["content-type"] || response.headers?.["Content-Type"] || "application/octet-stream";
const bytes = new Uint8Array(response.arrayBuffer.byteLength);
bytes.set(new Uint8Array(response.arrayBuffer));
const blob = new Blob([bytes.buffer], { type: contentType.split(";", 1)[0] });
const url = URL.createObjectURL(blob);
try {
return await loadImage(url, EXCALIDRAW_IMAGE_LOAD_TIMEOUT_MS);
} finally {
URL.revokeObjectURL(url);
}
} catch (error) {
console.warn("Mobile PDF Exporter remote image fallback failed", error);
return null;
}
})();
remoteCanvasImageCache.set(image, { source, image: imagePromise });
return imagePromise;
}
function drawLiveDrawingCanvas(
context: CanvasRenderingContext2D,
sourceCanvas: HTMLCanvasElement,
@ -6381,8 +6556,13 @@ function parseSvgLength(value: string | null): number {
return Number.isFinite(parsed) ? parsed : 0;
}
async function loadImage(url: string, timeoutMs = SVG_IMAGE_LOAD_TIMEOUT_MS): Promise<HTMLImageElement> {
async function loadImage(
url: string,
timeoutMs = SVG_IMAGE_LOAD_TIMEOUT_MS,
crossOrigin: "anonymous" | "use-credentials" | null = null
): Promise<HTMLImageElement> {
const image = new Image();
if (crossOrigin) image.crossOrigin = crossOrigin;
let timeout = 0;
await new Promise<void>((resolve, reject) => {
timeout = window.setTimeout(() => reject(new Error("Image load timed out.")), timeoutMs);
@ -6635,9 +6815,14 @@ function hasExportableContent(container: HTMLElement): boolean {
}
async function waitForImages(container: HTMLElement, timeoutMs: number): Promise<void> {
const images = Array.from(container.querySelectorAll("img"))
.filter((image) => !image.complete);
if (!images.length) return;
await waitForImagesInElements([container], timeoutMs);
}
async function waitForImagesInElements(elements: Iterable<Element>, timeoutMs: number): Promise<boolean> {
const images = Array.from(new Set(
Array.from(elements).flatMap((element) => Array.from(element.querySelectorAll("img")))
)).filter((image) => !image.complete);
if (!images.length) return false;
const adaptiveTimeout = Math.min(timeoutMs, Math.max(360, images.length * 260));
const imagePromise = Promise.all(
images.map(async (image) => {
@ -6649,6 +6834,7 @@ async function waitForImages(container: HTMLElement, timeoutMs: number): Promise
);
await waitForPromiseOrTimeout(imagePromise, adaptiveTimeout);
return true;
}
async function nextAnimationFrame(timeoutMs = FRAME_WAIT_TIMEOUT_MS): Promise<void> {

View file

@ -68,14 +68,22 @@ test("live capture avoids virtual-scroll loss, blank trailing pages, and split t
assert.match(source, /surface\.mode === "preview" && !hasRenderedContent\(rootEl\)/);
assert.match(source, /await waitForRenderedContent\(rootEl, 1800\)/);
assert.match(source, /scrollEl\.dispatchEvent\(new Event\("scroll"\)\)/);
assert.match(source, /await waitForPreviewDomStable\(rootEl, 750\)/);
assert.match(source, /await waitForPreviewDomStable\(rootEl, 360\)/);
assert.match(source, /function sortTextFragmentsForDrawing/);
assert.doesNotMatch(source, /function mergeAdjacentFragments/);
assert.match(source, /await waitForLiveSurfaceSettled\(rootEl, scrollEl, signal\)/);
assert.match(source, /function settleLiveSurfaceAtScrollPosition/);
assert.match(source, /await settleLiveSurfaceAtScrollPosition\(rootEl, scrollEl, scrollPositions\[index\], signal\)/);
assert.match(source, /await settleLiveSurfaceAtScrollPosition\(rootEl, scrollEl, scrollPositions\[index\], signal, previewRenderer\)/);
assert.match(source, /Math\.abs\(scrollEl\.scrollTop - expectedTop\) <= 1\.5/);
assert.match(source, /if \(rootEl\.querySelector\("img"\)\) \{\s*await waitForImages\(rootEl, Math\.min\(IMAGE_WAIT_TIMEOUT_MS, 1100\)\);\s*await settleLiveSurfaceAtScrollPosition/);
assert.match(source, /function waitForLivePreviewRendererSettled/);
assert.match(source, /if \(previewRenderer\) \{\s*await waitForLivePreviewRendererSettled\(scrollEl, previewRenderer, signal\)/);
assert.match(source, /section\.el\?\.isConnected \? 1 : 0/);
assert.match(source, /function getUncapturedConnectedPreviewSectionElements/);
assert.match(source, /waitForImagesInElements\(connectedSections, 900\)/);
assert.match(source, /else if \(rootEl\.querySelector\("img"\)\) \{\s*await waitForImages\(rootEl, Math\.min\(IMAGE_WAIT_TIMEOUT_MS, 1100\)\);\s*await settleLiveSurfaceAtScrollPosition/);
assert.match(source, /let appendedPreviewMissingWindows = false/);
assert.match(source, /previewRenderer && !appendedPreviewMissingWindows && index === scrollPositions\.length - 1/);
assert.match(source, /function waitForImagesInElements/);
assert.match(source, /const imageSignature = Array\.from\(rootEl\.querySelectorAll\("img"\)\)/);
assert.match(source, /function waitForLiveSurfaceSettled/);
assert.match(source, /interface LiveSurfaceCaptureWindow/);
@ -97,6 +105,11 @@ test("live capture avoids virtual-scroll loss, blank trailing pages, and split t
assert.match(source, /if \(pixelCount > 8_000_000\) return fullBounds/);
assert.match(source, /nextBreak = moveBreakOutsideTextLines\(pageTop, nextBreak, pageHeightPx, sortedBlocks\)/);
assert.match(source, /function moveBreakOutsideTextLines/);
assert.match(source, /return enforceMaximumPageSpan\(breaks, contentHeightPx, pageHeightPx, sortedBlocks\)/);
assert.match(source, /function enforceMaximumPageSpan/);
assert.match(source, /while \(target - current > maximumSpan \+ 0\.5\)/);
assert.match(source, /let nextBreak = Math\.min\(idealBreak, textSafeBreak\)/);
assert.match(source, /isContentEnd \|\| target - current > PAGE_BREAK_MIN_ADVANCE_PX/);
assert.match(source, /const crossingTextLine = sortedBlocks/);
assert.match(source, /fragment\.priority === 1/);
assert.match(source, /const beforeLine = crossingTextLine\.top/);
@ -106,3 +119,16 @@ test("live capture avoids virtual-scroll loss, blank trailing pages, and split t
assert.doesNotMatch(source, /const maxBoxBottom/);
assert.doesNotMatch(source, /const maxKeepBottom/);
});
test("remote images fall back to Obsidian requests when canvas export is cross-origin blocked", async () => {
const source = await readFile(sourceUrl, "utf8");
assert.match(source, /function imageFragmentSliceToPngBytes/);
assert.match(source, /catch \(directError\) \{\s*const remoteImage = await loadRemoteImageForCanvas\(image\)/);
assert.match(source, /function loadRemoteImageForCanvas/);
assert.match(source, /const remoteCanvasImageCache = new WeakMap/);
assert.match(source, /return await loadImage\(source, REMOTE_IMAGE_CORS_TIMEOUT_MS, "anonymous"\)/);
assert.match(source, /requestUrl\(\{ url: source, method: "GET" \}\),\s*REMOTE_IMAGE_REQUEST_TIMEOUT_MS/);
assert.match(source, /if \(crossOrigin\) image\.crossOrigin = crossOrigin/);
assert.match(source, /new Blob\(\[bytes\.buffer\], \{ type: contentType\.split\(";", 1\)\[0\] \}\)/);
});

View file

@ -64,5 +64,6 @@
"0.3.61": "1.5.0",
"0.3.62": "1.5.0",
"0.3.63": "1.5.0",
"0.3.64": "1.5.0"
"0.3.64": "1.5.0",
"0.3.65": "1.5.0"
}