From 573b5a6a4166dc7db1217338238d842980b8b492 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 22 Oct 2026 12:50:47 +0800 Subject: [PATCH] fix(web): match exported PDF styling to the note Print preview used a separate navy and emerald palette. It now follows the editor's light prose, including account type size, line height, palette accents, custom CSS, and the note font. Saving the preview downloads a PDF with the heading outline. --- apps/desktop/src/main/index.mjs | 8 + apps/desktop/src/main/print-pdf.mjs | 9 + apps/desktop/src/main/print-pdf.test.mjs | 26 ++ apps/desktop/src/preload/print.cjs | 22 +- apps/web/package.json | 1 + apps/web/src/components/EditorPane.tsx | 1 + .../editor/useEditorDocumentActions.ts | 10 +- .../src/lib/note-print-appearance.test.mjs | 34 ++ apps/web/src/lib/note-print-appearance.ts | 82 ++++ apps/web/src/lib/note-print-outline.test.mjs | 75 ++++ apps/web/src/lib/note-print-outline.ts | 137 ++++++ apps/web/src/lib/note-print-pdf.ts | 81 ++++ apps/web/src/lib/note-print.ts | 2 + apps/web/src/note-print.ts | 60 ++- apps/web/src/styles/note-print-screen.css | 6 +- apps/web/src/styles/note-print.css | 390 ++++++++++++------ apps/web/src/styles/note-print.test.mjs | 13 + apps/web/src/vite-env.d.ts | 5 + bun.lock | 13 +- packages/shared/src/i18n/en-US.ts | 4 +- packages/shared/src/i18n/ja.ts | 4 +- packages/shared/src/i18n/pl.ts | 4 +- packages/shared/src/i18n/zh-CN.ts | 4 +- 23 files changed, 837 insertions(+), 154 deletions(-) create mode 100644 apps/desktop/src/main/print-pdf.mjs create mode 100644 apps/desktop/src/main/print-pdf.test.mjs create mode 100644 apps/web/src/lib/note-print-appearance.test.mjs create mode 100644 apps/web/src/lib/note-print-appearance.ts create mode 100644 apps/web/src/lib/note-print-outline.test.mjs create mode 100644 apps/web/src/lib/note-print-outline.ts create mode 100644 apps/web/src/lib/note-print-pdf.ts diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 30fbe800..9e49c95d 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -30,6 +30,7 @@ import { mountedInstallerCandidates, } from "./installation-location.mjs"; import { userDataDirectoryFromArguments } from "./user-data-directory.mjs"; +import { PRINT_PDF_OPTIONS } from "./print-pdf.mjs"; import { isAllowedAppNavigation, isAllowedPrintPreviewUrl, isResourceDownloadUrl } from "./window-open-policy.mjs"; import { showWindow } from "./window-visibility.mjs"; import { globalShortcutAccelerator, normalizeGlobalShortcutBinding, replaceGlobalShortcut, toggleMainWindow } from "./global-shortcut.mjs"; @@ -1710,6 +1711,13 @@ const startApplication = async () => { ipcMain.on("desktop:local-data-reset-available-sync", (event) => { event.returnValue = process.platform === "darwin" && app.isPackaged && !requestedUserDataDirectory; }); + ipcMain.handle("desktop:print-pdf", async (event) => { + const appUrl = mainWindow?.webContents.getURL(); + if (!appUrl || !isAllowedPrintPreviewUrl(event.sender.getURL(), appUrl)) { + throw new Error("Only the note print preview can save a PDF"); + } + return event.sender.printToPDF(PRINT_PDF_OPTIONS); + }); ipcMain.handle("desktop:sidecar-request", async (_event, method, params) => { if (!sidecar) throw new Error("EdgeEver sidecar is unavailable"); const result = await sidecar.request(method, params); diff --git a/apps/desktop/src/main/print-pdf.mjs b/apps/desktop/src/main/print-pdf.mjs new file mode 100644 index 00000000..966d49bd --- /dev/null +++ b/apps/desktop/src/main/print-pdf.mjs @@ -0,0 +1,9 @@ +export const PRINT_PDF_OPTIONS = { + printBackground: true, + preferCSSPageSize: true, + displayHeaderFooter: false, + // Outline entries are ignored unless the PDF is also tagged. + generateDocumentOutline: true, + generateTaggedPDF: true, + margins: { top: 0, bottom: 0, left: 0, right: 0 }, +}; diff --git a/apps/desktop/src/main/print-pdf.test.mjs b/apps/desktop/src/main/print-pdf.test.mjs new file mode 100644 index 00000000..543b4a03 --- /dev/null +++ b/apps/desktop/src/main/print-pdf.test.mjs @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; +import { PRINT_PDF_OPTIONS } from "./print-pdf.mjs"; + +const read = (url) => readFileSync(new URL(url, import.meta.url), "utf8"); + +describe("desktop note PDF", () => { + test("asks Chromium for a heading outline in the viewer sidebar", () => { + expect(PRINT_PDF_OPTIONS).toMatchObject({ + generateDocumentOutline: true, + generateTaggedPDF: true, + preferCSSPageSize: true, + displayHeaderFooter: false, + }); + }); + + test("the print window can only save its own PDF", () => { + const preload = read("../preload/print.cjs"); + const main = read("./index.mjs"); + expect(preload).toContain('ipcRenderer.invoke("desktop:print-pdf")'); + expect(preload).not.toContain("edgeeverDesktop"); + expect(main).toContain('ipcMain.handle("desktop:print-pdf"'); + expect(main).toContain("isAllowedPrintPreviewUrl"); + expect(main).toContain("PRINT_PDF_OPTIONS"); + }); +}); diff --git a/apps/desktop/src/preload/print.cjs b/apps/desktop/src/preload/print.cjs index bdeb5739..61a96d6a 100644 --- a/apps/desktop/src/preload/print.cjs +++ b/apps/desktop/src/preload/print.cjs @@ -1 +1,21 @@ -// Print windows deliberately expose no desktop bridge. +const { contextBridge, ipcRenderer } = require("electron"); + +const normalizeIpcBytes = (value) => { + if (value instanceof Uint8Array) { + const copy = new Uint8Array(value.byteLength); + copy.set(value); + return copy; + } + if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0)); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)); + } + if (Array.isArray(value)) return Uint8Array.from(value); + if (value && typeof value === "object" && value.type === "Buffer") return normalizeIpcBytes(value.data); + return new Uint8Array(); +}; + +// The print window exposes only PDF saving. It does not get the desktop bridge. +contextBridge.exposeInMainWorld("edgeeverPrint", Object.freeze({ + savePdf: () => ipcRenderer.invoke("desktop:print-pdf").then(normalizeIpcBytes), +})); diff --git a/apps/web/package.json b/apps/web/package.json index 162e5df1..e086937c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,7 @@ "motion": "^12.43.0", "nanoid": "^6.0.1", "pagedjs": "^0.4.3", + "pdf-lib": "1.17.1", "streamdown": "^2.6.0", "use-stick-to-bottom": "^1.1.6" } diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index bca3cc7c..1ddebac4 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -2838,6 +2838,7 @@ const RichEditorPane = ({ hydratedEditorMemoId, markdownSource, memo, + noteProse, notebookName: notebookOptions.find((notebook) => notebook.id === memo?.notebookId)?.name ?? "", onDocumentActionConsumed, onSaveAsTemplate, diff --git a/apps/web/src/components/editor/useEditorDocumentActions.ts b/apps/web/src/components/editor/useEditorDocumentActions.ts index 50a93186..ded8ef02 100644 --- a/apps/web/src/components/editor/useEditorDocumentActions.ts +++ b/apps/web/src/components/editor/useEditorDocumentActions.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Editor } from "@tiptap/react"; -import { docToMarkdown, markdownToDoc, type MemoDetail, type TiptapDoc } from "@edgeever/shared"; +import { docToMarkdown, markdownToDoc, type MemoDetail, type ResolvedNoteProse, type TiptapDoc } from "@edgeever/shared"; +import { useMermaidTheme } from "@/components/ThemeProvider"; import type { MemoDocumentActionRequest } from "@/lib/app-helpers"; +import { notePrintAppearanceFromProse } from "@/lib/note-print-appearance"; import { buildSingleNoteMarkdown, downloadMarkdownFile } from "@/lib/note-markdown-export"; import { downloadNoteHtmlFile, getHtmlImageEmbedNoticeKind } from "@/lib/note-html-export"; import { NOTE_HTML_FULL_STYLES } from "@/lib/note-html-export-assets"; @@ -25,6 +27,7 @@ export const useEditorDocumentActions = ({ hydratedEditorMemoId, markdownSource, memo, + noteProse, notebookName, onDocumentActionConsumed, onSaveAsTemplate, @@ -41,6 +44,7 @@ export const useEditorDocumentActions = ({ hydratedEditorMemoId: string | null; markdownSource: string; memo: MemoDetail | null; + noteProse: ResolvedNoteProse; notebookName: string; onDocumentActionConsumed?: (requestId: number) => void; onSaveAsTemplate: (memo: MemoDetail, name: string) => Promise; @@ -50,6 +54,7 @@ export const useEditorDocumentActions = ({ useMobilePlainTextEditor: boolean; }) => { const { t, i18n } = useTranslation(); + const { mermaidTheme } = useMermaidTheme(); const [shareOpen, setShareOpen] = useState(false); const [imageShareOpen, setImageShareOpen] = useState(false); const [imageShareSource, setImageShareSource] = useState(null); @@ -119,6 +124,7 @@ export const useEditorDocumentActions = ({ updatedAt: formatDateTime(memo.updatedAt), html: serializeNoteDocumentForPrint(editor, content.document), language: i18n.resolvedLanguage ?? i18n.language, + appearance: notePrintAppearanceFromProse(noteProse, mermaidTheme), labels: { close: t("editor.pdfExport.close"), error: t("editor.pdfExport.error"), @@ -134,7 +140,7 @@ export const useEditorDocumentActions = ({ if (!opened) { window.alert(t("editor.pdfExport.popupBlocked")); } - }, [editor, i18n.language, i18n.resolvedLanguage, memo, notebookName, resolveExportContent, t, tagsText, title]); + }, [editor, i18n.language, i18n.resolvedLanguage, memo, mermaidTheme, noteProse, notebookName, resolveExportContent, t, tagsText, title]); const handleExportMarkdown = useCallback(() => { const content = resolveExportContent(); diff --git a/apps/web/src/lib/note-print-appearance.test.mjs b/apps/web/src/lib/note-print-appearance.test.mjs new file mode 100644 index 00000000..68751a2b --- /dev/null +++ b/apps/web/src/lib/note-print-appearance.test.mjs @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { resolveNoteProse } from "@edgeever/shared"; +import { notePrintAppearanceCss, notePrintAppearanceFromProse } from "./note-print-appearance.ts"; + +describe("note print appearance", () => { + test("keeps native prose on the editor neutrals and passes the account type size", () => { + const appearance = notePrintAppearanceFromProse(resolveNoteProse({ + fontSize: 18, + lineHeight: 1.5, + palette: "native", + customCss: "", + }), "github-light", { choice: "system", customFamily: "" }); + const css = notePrintAppearanceCss(appearance); + + expect(appearance.mermaidTheme).toBe("github-light"); + expect(appearance.fontFamily).toBeNull(); + expect(css).toContain("--editor-body-font-size: 18px"); + expect(css).toContain("--editor-body-line-height: 1.5"); + expect(css).not.toContain("color: #"); + }); + + test("tints links, bold, and checked boxes with the account palette", () => { + const css = notePrintAppearanceCss(notePrintAppearanceFromProse(resolveNoteProse({ + palette: "teal", + customCss: "strong { color: #112233 }", + }), "not-a-theme", { choice: "system", customFamily: "" })); + + expect(css).toContain("color: #14796E"); + expect(css).toContain("color: #20AD9D"); + expect(css).toContain("accent-color: #20AD9D"); + expect(css).toContain(".edgeever-editor .ProseMirror strong { color: #112233; }"); + expect(css).not.toContain("not-a-theme"); + }); +}); \ No newline at end of file diff --git a/apps/web/src/lib/note-print-appearance.ts b/apps/web/src/lib/note-print-appearance.ts new file mode 100644 index 00000000..041ad1de --- /dev/null +++ b/apps/web/src/lib/note-print-appearance.ts @@ -0,0 +1,82 @@ +import { + NOTE_PROSE_PALETTES, + noteProseCssVariables, + type NoteProsePaletteChoice, + type ResolvedNoteProse, +} from "@edgeever/shared"; +import { isRemoteNoteImageFont } from "@edgeever/shared/font-catalog-data"; +import { LEGACY_BODY_FONTS } from "@edgeever/shared/font-defaults"; +import type { BodyFontId } from "@edgeever/shared/font-loader"; +import type { MermaidThemeName } from "@/components/ThemeProvider"; +import { MERMAID_THEME_NAMES } from "@/components/ThemeProvider"; +import { sanitizeAndScopeCss } from "@/lib/css-sandbox"; +import { + readEditorBodyFontPreference, + resolveEditorBodyFontStack, + type EditorBodyFontPreference, +} from "@/lib/editor-body-font"; + +export type NotePrintAppearance = { + palette: NoteProsePaletteChoice; + cssVariables: Record; + customCss: string; + fontFamily: string | null; + fontId: BodyFontId | null; + mermaidTheme: MermaidThemeName; +}; + +const CSS_VARIABLE_NAME = /^--[a-z0-9-]+$/i; +const CSS_DECLARATION_VALUE = /^[^;{}]+$/; + +const loadableBodyFontId = (choice: string): BodyFontId | null => { + if (choice === "system" || choice === "custom") return null; + if (isRemoteNoteImageFont(choice) || Object.hasOwn(LEGACY_BODY_FONTS, choice)) return choice as BodyFontId; + return null; +}; + +export const notePrintAppearanceFromProse = ( + prose: ResolvedNoteProse, + mermaidTheme: string, + font: EditorBodyFontPreference = readEditorBodyFontPreference(), +): NotePrintAppearance => ({ + palette: prose.palette, + cssVariables: noteProseCssVariables(prose), + customCss: sanitizeAndScopeCss(prose.customCss), + fontFamily: resolveEditorBodyFontStack(font) ?? null, + fontId: loadableBodyFontId(font.choice), + mermaidTheme: (MERMAID_THEME_NAMES as readonly string[]).includes(mermaidTheme) + ? mermaidTheme as MermaidThemeName + : "zinc-light", +}); + +const paletteRules = (palette: NoteProsePaletteChoice) => { + if (palette === "native") return ""; + const colors = NOTE_PROSE_PALETTES[palette]; + if (!colors) return ""; + return [ + ".edgeever-print-content a:not(.edgeever-attachment-link):not([href*=\"/api/v1/resources/\"]) {", + ` color: ${colors.link};`, + ` text-decoration-color: color-mix(in srgb, ${colors.link} 45%, transparent);`, + "}", + ".edgeever-print-content strong,", + ".edgeever-print-content b {", + ` color: ${colors.accent};`, + "}", + ".edgeever-print-content ul[data-type=\"taskList\"] li[data-checked] > label input {", + ` accent-color: ${colors.accent};`, + "}", + ].join("\n"); +}; + +/** Account prose settings for the print page. Values come from the note palette, not from the message text. */ +export const notePrintAppearanceCss = (appearance: NotePrintAppearance) => { + const declarations = Object.entries(appearance.cssVariables) + .filter(([name, value]) => CSS_VARIABLE_NAME.test(name) && CSS_DECLARATION_VALUE.test(value)) + .map(([name, value]) => `${name}: ${value}`); + if (appearance.fontFamily && CSS_DECLARATION_VALUE.test(appearance.fontFamily)) { + declarations.push(`--editor-body-font-family: ${appearance.fontFamily}`); + } + return [`:root { ${declarations.join("; ")}; }`, paletteRules(appearance.palette), appearance.customCss] + .filter(Boolean) + .join("\n"); +}; diff --git a/apps/web/src/lib/note-print-outline.test.mjs b/apps/web/src/lib/note-print-outline.test.mjs new file mode 100644 index 00000000..b133a9b4 --- /dev/null +++ b/apps/web/src/lib/note-print-outline.test.mjs @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { parseHTML } from "linkedom"; +import { PDFDocument, PDFHexString, PDFName } from "pdf-lib"; +import { addPdfOutline, collectPrintOutline } from "./note-print-outline.ts"; + +const rect = (top, height) => () => ({ + top, height, left: 0, width: 100, right: 100, bottom: top + height, x: 0, y: top, toJSON() { return {}; }, +}); + +const outlineTitles = (pdf) => { + const titles = []; + const seen = new Set(); + const visit = (ref) => { + if (!ref || seen.has(ref.toString())) return; + seen.add(ref.toString()); + const item = pdf.context.lookup(ref); + const title = item.get?.(PDFName.of("Title")); + if (title instanceof PDFHexString) titles.push(title.decodeText()); + visit(item.get?.(PDFName.of("First"))); + visit(item.get?.(PDFName.of("Next"))); + }; + visit(pdf.catalog.get(PDFName.of("Outlines")) && pdf.context.lookup(pdf.catalog.get(PDFName.of("Outlines"))).get(PDFName.of("First"))); + return titles; +}; + +describe("print PDF outline", () => { + test("nests body headings under the note title and records where they sit", () => { + const { document } = parseHTML(""); + document.body.innerHTML = ` +

欢迎使用 EdgeEver

概览

海报

排版定制

+

海报

表达力

+ `; + for (const page of document.querySelectorAll(".pagedjs_page")) page.getBoundingClientRect = rect(0, 1000); + const headings = [...document.querySelectorAll("h1, h2, h3")]; + headings[0].getBoundingClientRect = rect(40, 30); + headings[1].getBoundingClientRect = rect(200, 20); + headings[2].getBoundingClientRect = rect(400, 20); + headings[3].getBoundingClientRect = rect(460, 20); + headings[5].getBoundingClientRect = rect(80, 20); + + expect(collectPrintOutline(document)).toEqual([ + { + title: "欢迎使用 EdgeEver", + pageIndex: 0, + topRatio: 0.04, + children: [ + { title: "概览", pageIndex: 0, topRatio: 0.2, children: [] }, + { + title: "海报", + pageIndex: 0, + topRatio: 0.4, + children: [{ title: "排版定制", pageIndex: 0, topRatio: 0.46, children: [] }], + }, + { title: "表达力", pageIndex: 1, topRatio: 0.08, children: [] }, + ], + }, + ]); + }); + + test("writes expanded sidebar entries that point at the heading page", async () => { + const pdf = await PDFDocument.create(); + pdf.addPage([100, 200]); + pdf.addPage([100, 200]); + addPdfOutline(pdf, [{ + title: "欢迎使用 EdgeEver", + pageIndex: 0, + topRatio: 0, + children: [{ title: "表达力", pageIndex: 1, topRatio: 0.25, children: [] }], + }]); + const saved = await PDFDocument.load(await pdf.save()); + expect(outlineTitles(saved)).toEqual(["欢迎使用 EdgeEver", "表达力"]); + const outlines = saved.context.lookup(saved.catalog.get(PDFName.of("Outlines"))); + expect(outlines.get(PDFName.of("Count")).asNumber()).toBe(2); + }); +}); diff --git a/apps/web/src/lib/note-print-outline.ts b/apps/web/src/lib/note-print-outline.ts new file mode 100644 index 00000000..ecae831f --- /dev/null +++ b/apps/web/src/lib/note-print-outline.ts @@ -0,0 +1,137 @@ +import { + PDFDocument, + PDFHexString, + PDFName, + PDFNull, + PDFNumber, + type PDFDict, + type PDFRef, +} from "pdf-lib"; + +const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6"; + +export type PrintOutlineEntry = { + title: string; + pageIndex: number; + /** Distance from the top of the page, as a fraction of the page height. */ + topRatio: number; + children: PrintOutlineEntry[]; +}; + +type FlatHeading = { + level: number; + title: string; + pageIndex: number; + topRatio: number; +}; + +const headingTitle = (element: HTMLElement) => element.textContent?.replace(/\s+/g, " ").trim().slice(0, 240) ?? ""; + +const topRatioWithin = (heading: HTMLElement, page: HTMLElement) => { + const pageRect = page.getBoundingClientRect(); + const headingRect = heading.getBoundingClientRect(); + if (pageRect.height <= 0) return 0; + const ratio = (headingRect.top - pageRect.top) / pageRect.height; + return Math.min(1, Math.max(0, ratio)); +}; + +const nestHeadings = (headings: readonly FlatHeading[]): PrintOutlineEntry[] => { + const roots: PrintOutlineEntry[] = []; + const ancestors: Array<{ level: number; entry: PrintOutlineEntry }> = []; + + for (const heading of headings) { + const entry: PrintOutlineEntry = { + title: heading.title, + pageIndex: heading.pageIndex, + topRatio: heading.topRatio, + children: [], + }; + while (ancestors.length > 0 && ancestors[ancestors.length - 1]!.level >= heading.level) ancestors.pop(); + const parent = ancestors[ancestors.length - 1]; + if (parent) parent.entry.children.push(entry); + else roots.push(entry); + ancestors.push({ level: heading.level, entry }); + } + + return roots; +}; + +/** Headings in the paginated preview, in the same order as the editor outline. */ +export const collectPrintOutline = (root: ParentNode): PrintOutlineEntry[] => { + const pages = [...root.querySelectorAll(".pagedjs_page")]; + const headings: FlatHeading[] = []; + + pages.forEach((page, pageIndex) => { + for (const heading of page.querySelectorAll(HEADING_SELECTOR)) { + if (heading.hasAttribute("data-split-from")) continue; + const title = headingTitle(heading); + if (!title) continue; + headings.push({ + level: Number(heading.tagName.slice(1)) || 1, + title, + pageIndex, + topRatio: topRatioWithin(heading, page), + }); + } + }); + + return nestHeadings(headings); +}; + +const descendantCount = (entry: PrintOutlineEntry): number => + entry.children.reduce((sum, child) => sum + 1 + descendantCount(child), 0); + +/** Writes the viewer's sidebar outline. Positive counts leave the entries expanded. */ +export const addPdfOutline = (pdf: PDFDocument, entries: readonly PrintOutlineEntry[]) => { + if (entries.length === 0 || pdf.getPageCount() === 0) return; + const pages = pdf.getPages(); + const context = pdf.context; + const outlines = context.obj({}); + const outlinesRef = context.register(outlines); + + const writeLevel = (nodes: readonly PrintOutlineEntry[], parentRef: PDFRef) => { + let previous: PDFRef | null = null; + let first: PDFRef | null = null; + let last: PDFRef | null = null; + + for (const node of nodes) { + const item = context.obj({}); + const ref = context.register(item); + first ??= ref; + last = ref; + const page = pages[Math.min(node.pageIndex, pages.length - 1)]!; + const y = page.getHeight() * (1 - node.topRatio); + item.set(PDFName.of("Title"), PDFHexString.fromText(node.title)); + item.set(PDFName.of("Parent"), parentRef); + item.set(PDFName.of("Dest"), context.obj([ + page.ref, + PDFName.of("XYZ"), + PDFNull, + PDFNumber.of(y), + PDFNull, + ])); + + const children = writeLevel(node.children, ref); + if (children) { + item.set(PDFName.of("First"), children.first); + item.set(PDFName.of("Last"), children.last); + item.set(PDFName.of("Count"), PDFNumber.of(descendantCount(node))); + } + if (previous) { + (context.lookup(previous) as PDFDict).set(PDFName.of("Next"), ref); + item.set(PDFName.of("Prev"), previous); + } + previous = ref; + } + + return first && last ? { first, last } : null; + }; + + const top = writeLevel(entries, outlinesRef); + if (!top) return; + outlines.set(PDFName.of("Type"), PDFName.of("Outlines")); + outlines.set(PDFName.of("First"), top.first); + outlines.set(PDFName.of("Last"), top.last); + outlines.set(PDFName.of("Count"), PDFNumber.of(entries.reduce((sum, entry) => sum + 1 + descendantCount(entry), 0))); + pdf.catalog.set(PDFName.of("Outlines"), outlinesRef); +}; diff --git a/apps/web/src/lib/note-print-pdf.ts b/apps/web/src/lib/note-print-pdf.ts new file mode 100644 index 00000000..cc1d03d5 --- /dev/null +++ b/apps/web/src/lib/note-print-pdf.ts @@ -0,0 +1,81 @@ +import { toCanvas } from "html-to-image"; +import { PDFDocument } from "pdf-lib"; +import { buildMarkdownFilename } from "@/lib/note-markdown-export"; +import { addPdfOutline, collectPrintOutline } from "@/lib/note-print-outline"; + +const A4_WIDTH = 595.28; + +const canvasToPng = (canvas: HTMLCanvasElement) => new Promise((resolve, reject) => { + canvas.toBlob(async (blob) => { + if (!blob) { + reject(new Error("Could not encode a PDF page")); + return; + } + resolve(new Uint8Array(await blob.arrayBuffer())); + }, "image/png"); +}); + +export const buildPdfFilename = (title: string, fallback: string) => + buildMarkdownFilename(title, fallback).replace(/\.md$/i, ".pdf"); + +export const downloadPdfBytes = (bytes: Uint8Array, filename: string) => { + const blob = new Blob([bytes], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.hidden = true; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +/** Browser export. Each preview page is captured, then the heading outline is attached. */ +export const renderPagedPreviewPdf = async (root: ParentNode) => { + const pageElements = [...root.querySelectorAll(".pagedjs_page")]; + if (pageElements.length === 0) throw new Error("Print preview has no pages"); + + const outline = collectPrintOutline(root); + const pdf = await PDFDocument.create(); + for (const [index, pageElement] of pageElements.entries()) { + const previousShadow = pageElement.style.boxShadow; + const pageLabel = pageElement.querySelector(".pagedjs_margin-bottom-center .pagedjs_margin-content"); + const previousLabel = pageLabel?.textContent ?? ""; + pageElement.style.boxShadow = "none"; + pageElement.classList.add("edgeever-print-pdf-capturing"); + if (pageLabel) pageLabel.textContent = `${index + 1} / ${pageElements.length}`; + try { + const rect = pageElement.getBoundingClientRect(); + const canvas = await toCanvas(pageElement, { + backgroundColor: "#ffffff", + cacheBust: false, + pixelRatio: 2, + skipAutoScale: true, + }); + const image = await pdf.embedPng(await canvasToPng(canvas)); + const width = A4_WIDTH; + const height = rect.width > 0 ? width * (rect.height / rect.width) : 841.89; + const page = pdf.addPage([width, height]); + page.drawImage(image, { x: 0, y: 0, width, height }); + } finally { + pageElement.style.boxShadow = previousShadow; + pageElement.classList.remove("edgeever-print-pdf-capturing"); + if (pageLabel) pageLabel.textContent = previousLabel; + } + } + + addPdfOutline(pdf, outline); + return pdf.save(); +}; + +type PrintBridge = { + savePdf?: () => Promise; +}; + +export const downloadNotePrintPdf = async (root: ParentNode, title: string, fallback: string) => { + const bridge = (window as Window & { edgeeverPrint?: PrintBridge }).edgeeverPrint; + const bytes = bridge?.savePdf ? await bridge.savePdf() : await renderPagedPreviewPdf(root); + if (bytes.byteLength === 0) throw new Error("PDF export returned an empty file"); + downloadPdfBytes(bytes, buildPdfFilename(title, fallback)); +}; diff --git a/apps/web/src/lib/note-print.ts b/apps/web/src/lib/note-print.ts index 5870c1ec..d6fc7f0f 100644 --- a/apps/web/src/lib/note-print.ts +++ b/apps/web/src/lib/note-print.ts @@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react"; import type { TiptapDoc } from "@edgeever/shared"; import { getAppPagePath, getMessageTargetOrigin } from "@/lib/app-page-path"; import { createClientUuid } from "@/lib/client-id"; +import type { NotePrintAppearance } from "@/lib/note-print-appearance"; export const NOTE_PRINT_MESSAGE = "edgeever:note-print"; export const NOTE_PRINT_READY_MESSAGE = "edgeever:note-print-ready"; @@ -27,6 +28,7 @@ export type NotePrintPayload = { html: string; language: string; labels: NotePrintLabels; + appearance: NotePrintAppearance; }; type NotePrintReadyMessage = { diff --git a/apps/web/src/note-print.ts b/apps/web/src/note-print.ts index 79f99d84..15753e41 100644 --- a/apps/web/src/note-print.ts +++ b/apps/web/src/note-print.ts @@ -8,14 +8,22 @@ import { NOTE_PRINT_READY_MESSAGE, type NotePrintPayload, } from "@/lib/note-print"; +import type { MermaidThemeName } from "@/components/ThemeProvider"; import { getMessageTargetOrigin } from "@/lib/app-page-path"; import { withEnvironmentTitlePrefix } from "@/lib/environment-title"; +import { notePrintAppearanceCss } from "@/lib/note-print-appearance"; +import { installEditorBodyFontFaces } from "@/lib/editor-body-font-faces"; +import { downloadNotePrintPdf } from "@/lib/note-print-pdf"; import katexStyles from "katex/dist/katex.min.css?inline"; +import githubHighlightStyles from "highlight.js/styles/github.css?inline"; import printStyles from "@/styles/note-print.css?inline"; import "@fontsource-variable/noto-sans-sc/wght.css"; import "@/styles/note-print-screen.css"; -const PRINT_FONT_FAMILY = '"Noto Sans SC Variable", sans-serif'; +const PRINT_FONT_FAMILY = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif'; +let printTitle = "EdgeEver"; +let printPreparingLabel = ""; +let printReadyLabel = ""; const toolbar = document.getElementById("print-toolbar"); const status = document.getElementById("print-status"); const hint = document.getElementById("print-hint"); @@ -30,8 +38,9 @@ const setText = (element: HTMLElement | null, value: string) => { } }; -const renderMermaidBlocks = async (root: HTMLElement) => { +const renderMermaidBlocks = async (root: HTMLElement, theme: MermaidThemeName, font: string) => { const blocks = Array.from(root.querySelectorAll("pre > code.language-mermaid")); + const palette = MERMAID_THEME_PALETTES[theme] ?? MERMAID_THEME_PALETTES["zinc-light"]; await Promise.all(blocks.map(async (code) => { const source = code.textContent?.trim(); const container = code.closest("pre"); @@ -41,10 +50,10 @@ const renderMermaidBlocks = async (root: HTMLElement) => { try { const svg = scaleMermaidSvg(renderMermaidSVG(source, { - ...THEMES["zinc-light"], - ...MERMAID_THEME_PALETTES["zinc-light"], + ...THEMES[theme] ?? THEMES["zinc-light"], + ...palette, transparent: true, - font: PRINT_FONT_FAMILY, + font, padding: 24, })); const figure = document.createElement("figure"); @@ -77,17 +86,19 @@ const waitForImages = async (root: HTMLElement) => { })); }; -const waitForPrintFonts = async (text: string) => { - await Promise.all([ - document.fonts.load(`400 16px ${PRINT_FONT_FAMILY}`, text), - document.fonts.load(`700 16px ${PRINT_FONT_FAMILY}`, text), - ]); +const waitForPrintFonts = async (text: string, fontId: NotePrintPayload["appearance"]["fontId"]) => { + installEditorBodyFontFaces(); + if (fontId) { + const { loadBodyFont } = await import("@edgeever/shared/font-loader"); + await loadBodyFont(fontId, text); + } await document.fonts.ready; }; const buildSource = (payload: NotePrintPayload) => { const article = document.createElement("article"); - article.className = "edgeever-print-document"; + article.className = "edgeever-print-document edgeever-editor"; + article.dataset.notePalette = payload.appearance.palette; article.lang = payload.language; const title = document.createElement("h1"); @@ -104,13 +115,16 @@ const buildSource = (payload: NotePrintPayload) => { } const content = document.createElement("div"); - content.className = "edgeever-print-content"; + content.className = "edgeever-print-content ProseMirror"; content.innerHTML = payload.html; article.appendChild(content); return article; }; const renderPreview = async (payload: NotePrintPayload) => { + printTitle = payload.title; + printPreparingLabel = payload.labels.preparing; + printReadyLabel = payload.labels.ready; document.documentElement.lang = payload.language; document.title = withEnvironmentTitlePrefix(`${payload.title} · EdgeEver`, { development: import.meta.env.DEV, @@ -122,10 +136,11 @@ const renderPreview = async (payload: NotePrintPayload) => { setText(printButton, payload.labels.print); const source = buildSource(payload); - await renderMermaidBlocks(source); + const printFont = payload.appearance.fontFamily ?? PRINT_FONT_FAMILY; + await renderMermaidBlocks(source, payload.appearance.mermaidTheme, printFont); highlightCodeBlocks(source); await Promise.all([ - waitForPrintFonts(source.textContent ?? ""), + waitForPrintFonts(source.textContent ?? "", payload.appearance.fontId), waitForImages(source), ]); @@ -136,7 +151,7 @@ const renderPreview = async (payload: NotePrintPayload) => { const previewer = new Previewer(); await previewer.preview( source, - [{ [window.location.href]: `${katexStyles}\n${printStyles}` }], + [{ [window.location.href]: `${katexStyles}\n${githubHighlightStyles}\n${printStyles}\n${notePrintAppearanceCss(payload.appearance)}` }], preview ); await document.fonts.ready; @@ -150,7 +165,20 @@ const renderPreview = async (payload: NotePrintPayload) => { }; closeButton?.addEventListener("click", () => window.close()); -printButton?.addEventListener("click", () => window.print()); +printButton?.addEventListener("click", () => { + if (!preview || !printButton || printButton.disabled) return; + printButton.disabled = true; + setText(status, printPreparingLabel); + void downloadNotePrintPdf(preview, printTitle, "EdgeEver").then(() => { + setText(status, printReadyLabel); + printButton.disabled = false; + }).catch((error) => { + console.error("Failed to save note PDF", error); + setText(status, printReadyLabel); + printButton.disabled = false; + window.print(); + }); +}); window.addEventListener("message", (event: MessageEvent) => { if ( diff --git a/apps/web/src/styles/note-print-screen.css b/apps/web/src/styles/note-print-screen.css index de3c88da..14cdb9d7 100644 --- a/apps/web/src/styles/note-print-screen.css +++ b/apps/web/src/styles/note-print-screen.css @@ -92,7 +92,11 @@ body { .pagedjs_page { flex: 0 0 auto; background: white; - font-family: "Noto Sans SC Variable", sans-serif; + color: #27272a; + font-family: var(--editor-body-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif); + font-size: var(--editor-body-font-size, 16px); + letter-spacing: -0.005em; + line-height: var(--editor-body-line-height, 1.65); box-shadow: 0 14px 35px rgb(15 23 42 / 0.18); } diff --git a/apps/web/src/styles/note-print.css b/apps/web/src/styles/note-print.css index 7c992381..87c33e88 100644 --- a/apps/web/src/styles/note-print.css +++ b/apps/web/src/styles/note-print.css @@ -4,9 +4,9 @@ @bottom-center { content: counter(page) " / " counter(pages); - color: #64748b; - font-family: "Noto Sans SC Variable", sans-serif; - font-size: 8.5pt; + color: #737373; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif; + font-size: 9pt; } } @@ -14,35 +14,34 @@ box-sizing: border-box; } -.edgeever-print-content .tiptap-mathematics-render[data-type="block-math"] { - max-width: 100%; - margin: 5mm 0; - overflow: hidden; - text-align: center; -} - .edgeever-print-document { - color: #172033; - font-family: "Noto Sans SC Variable", sans-serif; - font-size: 10.5pt; - line-height: 1.72; + --edgeever-system-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", "Noto Sans SC Variable", sans-serif; + color: #27272a; + font-family: var(--editor-body-font-family, var(--edgeever-system-font-family)); + font-size: var(--editor-body-font-size, 16px); + font-feature-settings: "chws" 1; + letter-spacing: -0.005em; + line-height: var(--editor-body-line-height, 1.65); overflow-wrap: anywhere; } .edgeever-print-title { - margin: 0 0 4mm; - color: #0f172a; - font-size: 24pt; - font-weight: 760; - line-height: 1.18; + margin: 0 0 0.75rem; + color: #1a1d21; + font-size: 1.5em; + font-weight: 650; + letter-spacing: -0.02em; + line-height: 1.28; } .edgeever-print-meta { - margin: 0 0 9mm; - border-bottom: 0.3mm solid #dbe4ea; - padding-bottom: 4mm; - color: #64748b; - font-size: 8.5pt; + margin: 0 0 1.5rem; + border-bottom: 1px solid #e5e5e5; + padding-bottom: 0.75rem; + color: #737373; + font-size: 0.8125rem; + letter-spacing: 0; + line-height: 1.45; } .edgeever-print-content > :first-child { @@ -51,41 +50,95 @@ .edgeever-print-content h1, .edgeever-print-content h2, -.edgeever-print-content h3 { +.edgeever-print-content h3, +.edgeever-print-content h4, +.edgeever-print-content h5, +.edgeever-print-content h6 { break-after: avoid-page; - color: #0f172a; - line-height: 1.25; + font-weight: 600; } .edgeever-print-content h1 { - margin: 8mm 0 3.5mm; - font-size: 19pt; + margin: 0 0 0.85rem; + color: #1a1d21; + font-size: 1.5em; + font-weight: 650; + letter-spacing: -0.02em; + line-height: 1.28; } .edgeever-print-content h2 { - margin: 7mm 0 3mm; - font-size: 15pt; + margin: 1.4rem 0 0.6rem; + color: #27272a; + font-size: 1.25em; + letter-spacing: -0.015em; + line-height: 1.32; } .edgeever-print-content h3 { - margin: 6mm 0 2.5mm; - font-size: 12.5pt; + margin: 1.15rem 0 0.5rem; + color: #27272a; + font-size: 1.125em; + letter-spacing: -0.01em; + line-height: 1.38; +} + +.edgeever-print-content h4 { + margin: 1.05rem 0 0.45rem; + color: #1e293b; + font-size: 1.0625em; + letter-spacing: -0.006em; + line-height: 1.4; +} + +.edgeever-print-content h5 { + margin: 0.95rem 0 0.4rem; + color: #334155; + font-size: 1em; + line-height: 1.42; +} + +.edgeever-print-content h6 { + margin: 0.85rem 0 0.35rem; + color: #334155; + font-size: 0.9375em; + line-height: 1.45; } .edgeever-print-content p { - margin: 0 0 3.5mm; + margin: 0 0 var(--editor-paragraph-spacing, 8px); orphans: 3; widows: 3; } +.edgeever-print-content strong, +.edgeever-print-content b { + font-synthesis: weight style; + font-weight: 800; +} + +.edgeever-print-content em, +.edgeever-print-content i { + font-synthesis: weight style; + font-style: italic; +} + .edgeever-print-content ul, .edgeever-print-content ol { - margin: 0 0 4mm; - padding-left: 6mm; + margin: 0 0 1rem; + padding-left: 1.5rem; +} + +.edgeever-print-content ul { + list-style-type: disc; +} + +.edgeever-print-content ol { + list-style-type: decimal; } .edgeever-print-content li { - margin: 1mm 0; + margin: 0.25rem 0; } .edgeever-print-content ul[data-type="taskList"] { @@ -96,7 +149,7 @@ .edgeever-print-content ul[data-type="taskList"] li[data-checked] { display: flex; align-items: flex-start; - gap: 2mm; + gap: 0.55rem; break-inside: avoid-page; } @@ -104,12 +157,12 @@ display: inline-flex; flex: 0 0 auto; align-items: center; - margin-top: 0.8mm; + margin-top: 0.2rem; } .edgeever-print-content ul[data-type="taskList"] li[data-checked] > label input { - width: 4mm; - height: 4mm; + width: 1rem; + height: 1rem; margin: 0; accent-color: #1a1d21; } @@ -123,71 +176,152 @@ margin-bottom: 0; } +.edgeever-print-content ul[data-type="taskList"] li[data-checked="true"] > div > p { + color: #737373; + text-decoration: line-through; + text-decoration-color: #a6a6a6; +} + .edgeever-print-content ul[data-type="taskList"] ul[data-type="taskList"] { - margin: 1mm 0 0; - padding-left: 6mm; + margin: 0.25rem 0 0; + padding-left: 1.5rem; } .edgeever-print-content blockquote { break-inside: avoid-page; - margin: 4mm 0; - border-left: 1mm solid #10b981; - background: #f0fdf4; - padding: 3mm 4mm; - color: #334155; + margin: 0 0 1rem; + border: 0; + border-radius: 8px; + background: #f3f5f7; + padding: 0.75rem 1rem; + color: #3d4450; } .edgeever-print-content hr { - margin: 7mm 0; + margin: 1.5rem 0; border: 0; - border-top: 0.3mm solid #cbd5e1; + border-top: 1px solid #1a1d21; } .edgeever-print-content hr.edgeever-merge-divider, .edgeever-print-content hr[data-edgeever-merge-divider] { - margin: 8mm 0; + margin: 1.5rem 0; border: 0; - border-top: 0.5mm solid #66ca80; + border-top: 2px solid #1a1d21; background: transparent; height: 0; } .edgeever-print-content a { - color: #047857; + color: #404040; + font-weight: 500; text-decoration: underline; + text-decoration-color: color-mix(in srgb, #404040 45%, transparent); + text-underline-offset: 2px; } -.edgeever-print-content code { - border-radius: 1mm; - background: #f1f5f9; - padding: 0.2mm 0.8mm; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +.edgeever-print-content a.edgeever-note-link, +.edgeever-print-content a[href^="#memo="] { + display: inline-flex; + align-items: center; + gap: 0.2rem; + max-width: 100%; + padding: 0.03rem 0.32rem 0.05rem; + border: 1px solid #e5e5e5; + border-radius: 0.35rem; + background: #f5f5f5; + color: #404040; font-size: 0.9em; + font-weight: 500; + text-decoration-color: color-mix(in srgb, #737373 55%, transparent); + vertical-align: baseline; +} + +.edgeever-print-content a.edgeever-attachment-link, +.edgeever-print-content a[href*="/api/v1/resources/"] { + display: inline-flex; + align-items: center; + gap: 0.3rem; + max-width: 100%; + padding: 0.3rem 0.55rem; + border: 1px solid rgb(212 212 212 / 0.9); + border-radius: 0.45rem; + background: #fafafa; + color: #262626; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; + text-decoration: none; +} + +.edgeever-print-content a.edgeever-attachment-link::before, +.edgeever-print-content a[href*="/api/v1/resources/"]::before { + content: "📎"; + flex: 0 0 auto; + font-size: 0.8rem; +} + +.edgeever-print-content :not(pre) > code { + border: 0; + border-radius: 4px; + background: #e9edf1; + box-shadow: inset 0 0 0 1px rgb(61 68 80 / 0.08); + padding: 0.08em 0.28em; + color: #3d4450; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--editor-code-font-size, 14px); + font-weight: 450; } .edgeever-print-content pre, .edgeever-print-content .edgeever-code-source { break-inside: avoid-page; max-width: 100%; - margin: 4mm 0; - border: 0.3mm solid #dbe4ea; - border-radius: 2mm; - background: #f8fafc; - padding: 3.5mm 4mm; - color: #1e293b; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; - font-size: 8.5pt; - line-height: 1.55; + margin: 0 0 1rem; + border: 1px solid rgb(212 212 212 / 0.6); + border-radius: 8px; + background: rgb(250 250 250 / 0.75); + padding: 0.9rem 1rem; + color: #24292e; + box-shadow: 0 1px 2px rgb(24 24 24 / 0.03); + font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--editor-code-font-size, 14px); + font-weight: 550; + line-height: 1.6; overflow-wrap: anywhere; white-space: pre-wrap; } .edgeever-print-content pre code { + border: 0; + border-radius: 0; background: transparent; + box-shadow: none; padding: 0; + color: inherit; + font-size: inherit; + font-weight: inherit; +} + +.edgeever-print-content .tiptap-mathematics-render[data-type="block-math"] { + max-width: 100%; + margin: 1rem 0; + overflow: hidden; + text-align: center; +} + +.edgeever-print-content img { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 220mm; + margin: 1rem 0; + border: 1px solid #b8dfd0; + border-radius: 8px; + object-fit: contain; } -.edgeever-print-content img, .edgeever-print-mermaid svg { display: block; width: auto; @@ -201,14 +335,14 @@ .edgeever-print-mermaid { break-inside: avoid-page; max-width: 100%; - margin: 5mm auto; + margin: 1rem auto; } .edgeever-print-content [data-edgeever-image-gallery] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 3mm; - margin: 5mm 0; + gap: 0.65rem; + margin: 1rem 0; break-inside: avoid-page; } @@ -222,10 +356,9 @@ .edgeever-print-content [data-edgeever-image-gallery] > img { width: 100%; - height: 52mm; + height: 13rem; max-height: none; margin: 0; - border-radius: 2mm; object-fit: cover; } @@ -233,14 +366,31 @@ margin: 0 auto; } +.edgeever-print-mermaid text { + font-family: var(--editor-body-font-family, var(--edgeever-system-font-family)); +} + +.edgeever-print-content .tableWrapper { + max-width: 100%; + margin: 1.25rem 0; + overflow: hidden; + border: 1px solid rgb(212 212 212 / 0.7); + border-radius: 6px; + background: #fff; +} + .edgeever-print-content table { width: 100%; - margin: 5mm 0; - border-collapse: collapse; - font-size: 8.5pt; + margin: 1.25rem 0; + border-collapse: separate; + border-spacing: 0; line-height: 1.45; } +.edgeever-print-content .tableWrapper table { + margin: 0; +} + .edgeever-print-content thead { display: table-header-group; } @@ -251,20 +401,46 @@ .edgeever-print-content th, .edgeever-print-content td { - border: 0.3mm solid #cbd5e1; - padding: 2mm 2.5mm; + border: 0; + border-right: 1px solid rgb(229 229 229 / 0.8); + border-bottom: 1px solid rgb(229 229 229 / 0.8); + padding: 0.48rem 0.8rem; text-align: left; vertical-align: top; + overflow-wrap: anywhere; +} + +.edgeever-print-content th:last-child, +.edgeever-print-content td:last-child { + border-right: 0; +} + +.edgeever-print-content tr:last-child td { + border-bottom: 0; } .edgeever-print-content th { - background: #f1f5f9; - font-weight: 700; + background: rgb(245 245 245 / 0.75); + color: #181818; + font-size: 0.84em; + font-weight: 600; + letter-spacing: -0.01em; +} + +.edgeever-print-content tbody tr:nth-child(even) td { + background: rgb(250 250 250 / 0.5); +} + +.edgeever-print-content th p, +.edgeever-print-content td p { + margin: 0; + letter-spacing: inherit; + line-height: inherit; } .edgeever-print-content details { break-inside: avoid-page; - margin: 2mm 0; + margin: 0.15em 0; padding: 0; border: 0; background: transparent; @@ -282,23 +458,18 @@ .edgeever-print-content [data-edgeever-theme-block] { break-inside: avoid-page; - margin: 5mm 0; - border: 0.3mm solid #a7f3d0; - border-left: 1mm solid #059669; - border-radius: 2mm; - background: #f0fdf4; - padding: 3.5mm 4mm; + margin: 1.25rem 0; + border: 1px solid #dbeee5; + border-radius: 6px; + background: #fbfefc; + padding: 0.8rem 1rem; } .edgeever-print-content [data-theme-block-kind="chapter"] { - border-width: 1mm 0 0; - border-radius: 0; - background: transparent; -} - -.edgeever-print-content .tableWrapper { - max-width: 100%; - overflow: visible; + border-width: 0; + border-left: 4px solid #16a06e; + border-radius: 6px; + background: #f0f8f4; } .edgeever-print-content .edgeever-image-controls, @@ -307,32 +478,3 @@ .edgeever-print-content .edgeever-mermaid-source { display: none !important; } - -.hljs-comment, -.hljs-quote { - color: #64748b; -} - -.hljs-keyword, -.hljs-selector-tag, -.hljs-literal { - color: #7c3aed; -} - -.hljs-string, -.hljs-title, -.hljs-section { - color: #047857; -} - -.hljs-number, -.hljs-symbol, -.hljs-bullet { - color: #b45309; -} - -.hljs-built_in, -.hljs-type, -.hljs-attribute { - color: #0369a1; -} diff --git a/apps/web/src/styles/note-print.test.mjs b/apps/web/src/styles/note-print.test.mjs index 730a9af9..adc3f074 100644 --- a/apps/web/src/styles/note-print.test.mjs +++ b/apps/web/src/styles/note-print.test.mjs @@ -9,5 +9,18 @@ describe("note print page margin", () => { expect(css).not.toContain("@top-center"); expect(css).not.toContain("edgeever-note-title"); expect(css).not.toContain("string-set:"); + expect(css).not.toContain("edgeever-print-toc"); + }); + + test("uses the editor's neutral prose colors", () => { + expect(css).toContain("color: #27272a"); + expect(css).toContain("background: #f3f5f7"); + expect(css).toContain("color: #3d4450"); + expect(css).toContain("border-top: 1px solid #1a1d21"); + expect(css).toContain("font-weight: 800"); + expect(css).not.toContain("#047857"); + expect(css).not.toContain("#10b981"); + expect(css).not.toContain("#172033"); + expect(css).not.toContain("#0f172a"); }); }); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index e040c575..52745b89 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -255,6 +255,11 @@ interface DesktopRendererErrorDetails { exitCode?: number; } +interface EdgeEverPrintBridge { + savePdf: () => Promise; +} + interface Window { edgeeverDesktop?: EdgeEverDesktopBridge; + edgeeverPrint?: EdgeEverPrintBridge; } diff --git a/bun.lock b/bun.lock index 10adabdc..a2100b1d 100644 --- a/bun.lock +++ b/bun.lock @@ -103,7 +103,7 @@ }, "apps/desktop": { "name": "@edgeever/desktop", - "version": "1.121.0", + "version": "2.2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.5.1", "@edgeever/public-network": "workspace:*", @@ -120,7 +120,7 @@ }, "apps/extension": { "name": "@edgeever/extension", - "version": "0.2.1", + "version": "0.3.0", "dependencies": { "@joplin/turndown-plugin-gfm": "1.0.68", "@mixmark-io/domino": "2.2.0", @@ -271,6 +271,7 @@ "motion": "^12.43.0", "nanoid": "^6.0.1", "pagedjs": "^0.4.3", + "pdf-lib": "1.17.1", "streamdown": "^2.6.0", "use-stick-to-bottom": "^1.1.6", }, @@ -1214,6 +1215,10 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.88.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g=="], + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA=="], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ=="], + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], "@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="], @@ -3552,6 +3557,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw=="], + "pdfjs-dist": ["pdfjs-dist@6.2.108", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ=="], "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], @@ -5232,6 +5239,8 @@ "parse5-htmlparser2-tree-adapter/domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + "pdf-lib/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 645cca88..beb0d1f3 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -2572,8 +2572,8 @@ export const enUS = { pdfExport: { preparing: "Preparing print preview…", ready: "Print preview is ready", - hint: "Choose “Save as PDF” in the print dialog.", - print: "Print / Save PDF", + hint: "The downloaded PDF includes the heading outline in the viewer's sidebar.", + print: "Save PDF", close: "Close", error: "Could not generate the print preview", popupBlocked: "The browser blocked the print preview window. Allow pop-ups for EdgeEver and try again.", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 1c736eca..8dc00f8d 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -2561,8 +2561,8 @@ export const ja = { pdfExport: { preparing: "印刷プレビューを準備中…", ready: "印刷プレビューの準備ができました", - hint: "印刷ダイアログで「PDF として保存」を選んでください。", - print: "印刷 / PDF を保存", + hint: "ダウンロードした PDF には見出しの目次があり、ビューアのサイドバーで開けます。", + print: "PDF を保存", close: "閉じる", error: "印刷プレビューを生成できませんでした", popupBlocked: "ブラウザが印刷プレビューウィンドウをブロックしました。EdgeEver のポップアップを許可して再試行してください。", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 29239096..065624fd 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -2561,8 +2561,8 @@ export const pl = { pdfExport: { preparing: "Przygotowywanie podglądu wydruku…", ready: "Podgląd wydruku jest gotowy", - hint: "W oknie drukowania wybierz „Zapisz jako PDF”.", - print: "Drukuj / zapisz PDF", + hint: "Pobrany PDF zawiera konspekt nagłówków na pasku bocznym czytnika.", + print: "Zapisz PDF", close: "Zamknij", error: "Nie udało się wygenerować podglądu wydruku", popupBlocked: "Przeglądarka zablokowała okno podglądu wydruku. Zezwól EdgeEver na wyskakujące okna i spróbuj ponownie.", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 9e4ab74a..7383acd4 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -2570,8 +2570,8 @@ export const zhCN = { pdfExport: { preparing: "正在生成打印预览…", ready: "打印预览已就绪", - hint: "请在打印对话框中选择“另存为 PDF”。", - print: "打印 / 保存 PDF", + hint: "下载的 PDF 带有标题目录,可在阅读器左侧栏打开。", + print: "保存 PDF", close: "关闭", error: "打印预览生成失败", popupBlocked: "浏览器阻止了打印预览窗口,请允许 EdgeEver 打开弹出式窗口后重试。",