diff --git a/apps/admin-web/public/fonts/SourceHanSansSC-Regular.ttf b/apps/admin-web/public/fonts/SourceHanSansSC-Regular.ttf new file mode 100644 index 0000000..c8457b4 Binary files /dev/null and b/apps/admin-web/public/fonts/SourceHanSansSC-Regular.ttf differ diff --git a/apps/admin-web/src/lib/article-export.ts b/apps/admin-web/src/lib/article-export.ts index 43996d8..da5e386 100644 --- a/apps/admin-web/src/lib/article-export.ts +++ b/apps/admin-web/src/lib/article-export.ts @@ -25,19 +25,23 @@ const markdownRenderer = new MarkdownIt({ const PDF_PAGE_WIDTH_MM = 210 const PDF_PAGE_HEIGHT_MM = 297 -const PDF_MARGIN_X_MM = 18 -const PDF_MARGIN_Y_MM = 20 +const PDF_MARGIN_X_MM = 10 +const PDF_MARGIN_Y_MM = 12 const PDF_CONTENT_WIDTH_MM = PDF_PAGE_WIDTH_MM - PDF_MARGIN_X_MM * 2 const PDF_CONTENT_HEIGHT_MM = PDF_PAGE_HEIGHT_MM - PDF_MARGIN_Y_MM * 2 -const PDF_RENDER_WIDTH_PX = 658 -const PDF_CONTENT_HEIGHT_PX = Math.floor( - (PDF_RENDER_WIDTH_PX * PDF_CONTENT_HEIGHT_MM) / PDF_CONTENT_WIDTH_MM, -) -const PDF_PAGE_BREAK_SEARCH_PX = 140 -const PDF_PAGE_BREAK_BLANK_RUN_PX = 12 -const PDF_PAGE_BREAK_MIN_PROGRESS_RATIO = 0.68 -const PDF_PAGE_BREAK_ALPHA_THRESHOLD = 12 -const PDF_PAGE_BREAK_INK_THRESHOLD = 16 +const PDF_FONT_NAME = 'SourceHanSansSC' +const PDF_FONT_FILENAME = 'SourceHanSansSC-Regular.ttf' +const PDF_FONT_URL = '/fonts/SourceHanSansSC-Regular.ttf' +const PDF_TEXT_COLOR = '#111827' +const PDF_HEADING_COLOR = '#101828' +const PDF_MUTED_TEXT_COLOR = '#475467' +const PDF_LINK_COLOR = '#1677FF' +const PDF_BORDER_COLOR = '#D0D5DD' +const PDF_TABLE_HEADER_FILL = '#F2F4F7' +const PDF_CODE_FILL = '#F3F4F6' +const PDF_CODE_BLOCK_FILL = '#111827' +const PDF_CODE_BLOCK_TEXT = '#F9FAFB' +const PDF_IMAGE_MAX_HEIGHT_MM = 190 const DOCX_PAGE_WIDTH_DXA = 11906 const DOCX_PAGE_HEIGHT_DXA = 16838 const DOCX_PAGE_MARGIN_DXA = 1134 @@ -48,11 +52,6 @@ const DOCX_MUTED_TEXT_COLOR = '475467' const DOCX_LINK_COLOR = '1677FF' const DOCX_BORDER_COLOR = 'D0D5DD' -type PageBreakAvoidRange = { - start: number - end: number -} - type MarkdownTokenLike = { type?: string content?: string @@ -78,6 +77,41 @@ type WordImageData = { alt: string } +type PdfJsPDF = jsPDF + +type PdfContext = { + pdf: PdfJsPDF + y: number +} + +type PdfTextStyle = { + fontSize: number + lineHeight: number + color: string + bold?: boolean + italic?: boolean + code?: boolean +} + +type PdfBlockOptions = { + indent?: number + bullet?: string + before?: number + after?: number + borderLeft?: boolean + fillColor?: string + textColor?: string + padding?: number +} + +type PdfImageData = { + dataUrl: string + format: 'PNG' | 'JPEG' + width: number + height: number + alt: string +} + export function hasArticleMarkdownImages(markdown: string): boolean { return markdownTokensContainImage(markdownRenderer.parse(markdown || '', {})) } @@ -103,49 +137,25 @@ export async function downloadArticleDocument( } export async function downloadArticlePdf(options: ArticleExportOptions): Promise { - const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ - import('html2canvas'), - import('jspdf'), - ]) + const { jsPDF } = await import('jspdf') const title = normalizeTitle(options.title) const filenameBase = buildFilename(title || options.untitledLabel) - const renderHost = createPdfRenderHost(title, options.markdown) + const pdf = new jsPDF({ + compress: true, + format: 'a4', + orientation: 'portrait', + putOnlyUsedFonts: true, + unit: 'mm', + }) - document.body.append(renderHost) - try { - await waitForImages(renderHost) - const renderScale = getPdfRenderScale() - const avoidRanges = collectPdfPageBreakAvoidRanges(renderHost, renderScale) - const canvas = await html2canvas(renderHost, { - allowTaint: false, - backgroundColor: '#ffffff', - height: renderHost.scrollHeight, - imageTimeout: 8000, - logging: false, - scale: renderScale, - scrollX: 0, - scrollY: 0, - useCORS: true, - width: PDF_RENDER_WIDTH_PX, - windowHeight: renderHost.scrollHeight, - windowWidth: PDF_RENDER_WIDTH_PX, - }) + await registerPdfFont(pdf) + pdf.setProperties({ + creator: '省心推', + title: title || options.untitledLabel, + }) - const pdf = new jsPDF({ - compress: true, - format: 'a4', - orientation: 'portrait', - unit: 'mm', - }) - pdf.setProperties({ - creator: '省心推', - title: title || options.untitledLabel, - }) - appendCanvasPages(pdf, canvas, avoidRanges) - pdf.save(`${filenameBase}.pdf`) - } finally { - renderHost.remove() - } + await appendArticlePdfContent(pdf, title, options.markdown) + pdf.save(`${filenameBase}.pdf`) } function buildMarkdownDocument(title: string, markdown: string): string { @@ -1030,42 +1040,498 @@ function buildArticleHtml(title: string, markdown: string): string { return absolutizeArticleLinks(DOMPurify.sanitize(`${titleHtml}${rawBody}`)) } -function createPdfRenderHost(title: string, markdown: string): HTMLElement { - const host = document.createElement('section') - host.className = 'article-export-render' - host.style.position = 'absolute' - host.style.left = '-10000px' - host.style.top = '0' - host.style.width = `${PDF_RENDER_WIDTH_PX}px` - host.style.background = '#ffffff' - host.style.pointerEvents = 'none' - host.innerHTML = ` - -
${buildArticleHtml(title, markdown)}
- ` - return host +async function registerPdfFont(pdf: PdfJsPDF): Promise { + const response = await fetch(PDF_FONT_URL) + if (!response.ok) { + throw new Error('pdf_font_load_failed') + } + + const fontBase64 = arrayBufferToBase64(await response.arrayBuffer()) + pdf.addFileToVFS(PDF_FONT_FILENAME, fontBase64) + pdf.addFont(PDF_FONT_FILENAME, PDF_FONT_NAME, 'normal', undefined, 'Identity-H') + pdf.setFont(PDF_FONT_NAME, 'normal') +} + +async function appendArticlePdfContent(pdf: PdfJsPDF, title: string, markdown: string): Promise { + const template = document.createElement('template') + template.innerHTML = buildArticleHtml(title, markdown) + const context: PdfContext = { + pdf, + y: PDF_MARGIN_Y_MM, + } + + for (const child of Array.from(template.content.children)) { + await appendPdfElement(context, child) + } +} + +async function appendPdfElement( + context: PdfContext, + element: Element, + listState: { ordered: boolean; level: number; index: number } | null = null, +): Promise { + const tagName = element.tagName.toLowerCase() + + if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3' || tagName === 'h4' || tagName === 'h5' || tagName === 'h6') { + const level = Number.parseInt(tagName.slice(1), 10) + appendPdfTextBlock(context, getElementPdfText(element), getPdfHeadingStyle(level), { + before: level === 1 ? 0 : 6, + after: level === 1 ? 7 : 4, + }) + return + } + + if (tagName === 'ul' || tagName === 'ol') { + await appendPdfList(context, element, tagName === 'ol', listState?.level ?? 0) + return + } + + if (tagName === 'table') { + appendPdfTable(context, element) + return + } + + if (tagName === 'blockquote') { + appendPdfTextBlock(context, getElementPdfText(element), getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }), { + after: 5, + borderLeft: true, + indent: 5, + }) + return + } + + if (tagName === 'pre') { + appendPdfTextBlock(context, element.textContent ?? '', getPdfBodyStyle({ code: true, color: PDF_CODE_BLOCK_TEXT }), { + after: 5, + fillColor: PDF_CODE_BLOCK_FILL, + padding: 4, + }) + return + } + + if (tagName === 'hr') { + appendPdfDivider(context) + return + } + + if (tagName === 'p' && element.querySelector(':scope > img') && !getDirectText(element).trim()) { + for (const image of Array.from(element.querySelectorAll(':scope > img'))) { + await appendPdfImage(context, image) + } + return + } + + if (tagName === 'p' || tagName === 'div' || tagName === 'section' || tagName === 'article' || tagName === 'li') { + const text = getElementPdfText(element) + if (!text && tagName !== 'li') { + return + } + + appendPdfTextBlock(context, text, getPdfBodyStyle(), { + after: listState ? 3 : 5, + bullet: listState ? (listState.ordered ? `${listState.index}.` : '•') : undefined, + indent: listState ? Math.min(listState.level, 2) * 6 : 0, + }) + return + } + + const fallbackText = getElementPdfText(element) + if (fallbackText) { + appendPdfTextBlock(context, fallbackText, getPdfBodyStyle(), { after: 5 }) + } +} + +async function appendPdfList( + context: PdfContext, + element: Element, + ordered: boolean, + level: number, +): Promise { + let index = 1 + + for (const item of Array.from(element.children).filter((child) => child.tagName.toLowerCase() === 'li')) { + const itemInlineChildren: Node[] = [] + const nestedLists: Element[] = [] + + for (const child of Array.from(item.childNodes)) { + if ( + child.nodeType === Node.ELEMENT_NODE && + ['ul', 'ol'].includes((child as Element).tagName.toLowerCase()) + ) { + nestedLists.push(child as Element) + continue + } + + itemInlineChildren.push(child) + } + + const itemContainer = document.createElement('span') + itemInlineChildren.forEach((child) => itemContainer.append(child.cloneNode(true))) + appendPdfTextBlock(context, getElementPdfText(itemContainer), getPdfBodyStyle(), { + after: nestedLists.length ? 2 : 3, + bullet: ordered ? `${index}.` : '•', + indent: Math.min(level, 2) * 6, + }) + + for (const nestedList of nestedLists) { + await appendPdfList(context, nestedList, nestedList.tagName.toLowerCase() === 'ol', level + 1) + } + + index += 1 + } +} + +function appendPdfTextBlock( + context: PdfContext, + text: string, + style: PdfTextStyle, + options: PdfBlockOptions = {}, +): void { + const normalizedText = normalizePdfText(text) + const padding = options.padding ?? 0 + const indent = options.indent ?? 0 + const bulletWidth = options.bullet ? 8 : 0 + const x = PDF_MARGIN_X_MM + indent + bulletWidth + padding + const maxWidth = Math.max(20, PDF_CONTENT_WIDTH_MM - indent - bulletWidth - padding * 2) + const lines = wrapPdfText(context.pdf, normalizedText || ' ', maxWidth, style) + const blockHeight = lines.length * style.lineHeight + padding * 2 + + context.y += options.before ?? 0 + ensurePdfSpace(context, blockHeight) + + const blockTop = context.y + if (options.fillColor) { + context.pdf.setFillColor(options.fillColor) + context.pdf.roundedRect(PDF_MARGIN_X_MM + indent, blockTop, PDF_CONTENT_WIDTH_MM - indent, blockHeight, 2, 2, 'F') + } + + if (options.borderLeft) { + context.pdf.setDrawColor(PDF_BORDER_COLOR) + context.pdf.setLineWidth(1) + context.pdf.line(PDF_MARGIN_X_MM, blockTop + 0.5, PDF_MARGIN_X_MM, blockTop + blockHeight - 0.5) + } + + applyPdfTextStyle(context.pdf, { + ...style, + color: options.textColor ?? style.color, + }) + + if (options.bullet) { + context.pdf.text(options.bullet, PDF_MARGIN_X_MM + indent, context.y + padding + style.fontSize * 0.36) + } + + let lineY = context.y + padding + style.fontSize * 0.36 + for (const line of lines) { + context.pdf.text(line, x, lineY) + lineY += style.lineHeight + } + + context.y += blockHeight + (options.after ?? 0) +} + +function appendPdfTable(context: PdfContext, table: Element): void { + const rows = Array.from(table.querySelectorAll('tr')).map((row) => + Array.from(row.children) + .filter((cell) => ['td', 'th'].includes(cell.tagName.toLowerCase())) + .map((cell) => ({ + header: cell.tagName.toLowerCase() === 'th', + text: getElementPdfText(cell), + })), + ) + + const visibleRows = rows.filter((row) => row.length) + if (!visibleRows.length) { + return + } + + const columnCount = Math.max(...visibleRows.map((row) => row.length)) + const columnWidth = PDF_CONTENT_WIDTH_MM / columnCount + const cellPadding = 2.5 + const style = getPdfBodyStyle({ fontSize: 9.5, lineHeight: 5 }) + + context.y += 3 + + for (const row of visibleRows) { + const cellLines = Array.from({ length: columnCount }, (_, index) => { + const cell = row[index] + return { + header: Boolean(cell?.header), + lines: wrapPdfText(context.pdf, normalizePdfText(cell?.text ?? ' '), columnWidth - cellPadding * 2, style), + } + }) + const rowHeight = Math.max(...cellLines.map((cell) => cell.lines.length * style.lineHeight + cellPadding * 2), 9) + + ensurePdfSpace(context, rowHeight) + + for (let index = 0; index < columnCount; index += 1) { + const x = PDF_MARGIN_X_MM + index * columnWidth + const cell = cellLines[index] + context.pdf.setDrawColor(PDF_BORDER_COLOR) + context.pdf.setLineWidth(0.2) + if (cell?.header) { + context.pdf.setFillColor(PDF_TABLE_HEADER_FILL) + context.pdf.rect(x, context.y, columnWidth, rowHeight, 'FD') + } else { + context.pdf.rect(x, context.y, columnWidth, rowHeight) + } + + applyPdfTextStyle(context.pdf, { + ...style, + color: cell?.header ? PDF_MUTED_TEXT_COLOR : PDF_TEXT_COLOR, + }) + let lineY = context.y + cellPadding + style.fontSize * 0.36 + for (const line of cell?.lines ?? ['']) { + context.pdf.text(line, x + cellPadding, lineY) + lineY += style.lineHeight + } + } + + context.y += rowHeight + } + + context.y += 5 +} + +function appendPdfDivider(context: PdfContext): void { + ensurePdfSpace(context, 8) + context.y += 3 + context.pdf.setDrawColor('#E5E7EB') + context.pdf.setLineWidth(0.3) + context.pdf.line(PDF_MARGIN_X_MM, context.y, PDF_PAGE_WIDTH_MM - PDF_MARGIN_X_MM, context.y) + context.y += 5 +} + +async function appendPdfImage(context: PdfContext, image: HTMLImageElement): Promise { + const imageData = await loadPdfImageData(image) + if (!imageData) { + const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? '' + if (alt.trim()) { + appendPdfTextBlock(context, alt, getPdfBodyStyle({ color: PDF_MUTED_TEXT_COLOR }), { after: 4 }) + } + return + } + + const size = fitImageSize(imageData.width, imageData.height, PDF_CONTENT_WIDTH_MM, PDF_IMAGE_MAX_HEIGHT_MM) + ensurePdfSpace(context, size.height + 6) + const x = PDF_MARGIN_X_MM + (PDF_CONTENT_WIDTH_MM - size.width) / 2 + context.pdf.addImage(imageData.dataUrl, imageData.format, x, context.y, size.width, size.height, undefined, 'FAST') + context.y += size.height + 6 +} + +async function loadPdfImageData(image: HTMLImageElement): Promise { + const src = image.getAttribute('src') + if (!src) { + return null + } + + const resolvedSrc = resolveBrowserURL(src) + const fetchSrc = buildWordImageFetchURL(resolvedSrc) + const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? '' + + try { + const response = await fetch(fetchSrc, buildWordImageFetchInit(fetchSrc)) + if (!response.ok) { + return await convertPdfImageElementToData(image, alt) + } + + const blob = await response.blob() + const dataUrl = await blobToDataURL(blob) + const dimensions = await readBrowserImageSize(dataUrl) + const format = inferPdfImageFormat(dataUrl, blob.type) + + if (!format) { + return await convertPdfImageSourceToPng(dataUrl, alt) + } + + return { + alt, + dataUrl, + format, + width: dimensions.width || image.naturalWidth || DOCX_IMAGE_MAX_WIDTH_PX, + height: dimensions.height || image.naturalHeight || 360, + } + } catch { + return await convertPdfImageElementToData(image, alt) + } +} + +function convertPdfImageElementToData(image: HTMLImageElement, alt: string): Promise { + if (!image.complete || !image.naturalWidth || !image.naturalHeight) { + return Promise.resolve(null) + } + + return convertPdfImageSourceToPng(image.src, alt) +} + +function convertPdfImageSourceToPng(src: string, alt: string): Promise { + return new Promise((resolve) => { + const image = new Image() + image.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, image.naturalWidth || image.width) + canvas.height = Math.max(1, image.naturalHeight || image.height) + const context = canvas.getContext('2d') + if (!context) { + resolve(null) + return + } + + context.drawImage(image, 0, 0, canvas.width, canvas.height) + resolve({ + alt, + dataUrl: canvas.toDataURL('image/png'), + format: 'PNG', + width: canvas.width, + height: canvas.height, + }) + } catch { + resolve(null) + } + } + image.onerror = () => resolve(null) + image.crossOrigin = 'anonymous' + image.src = src + }) +} + +function inferPdfImageFormat(src: string, contentType?: string | null): PdfImageData['format'] | null { + const rawType = ( + src.match(/^data:image\/(png|jpe?g);/i)?.[1] ?? + src.split(/[?#]/, 1)[0]?.match(/\.(png|jpe?g)$/i)?.[1] ?? + contentType?.match(/^image\/(png|jpe?g)/i)?.[1] ?? + '' + ).toLowerCase() + + if (rawType === 'jpg' || rawType === 'jpeg') { + return 'JPEG' + } + if (rawType === 'png') { + return 'PNG' + } + return null +} + +function ensurePdfSpace(context: PdfContext, requiredHeight: number): void { + if (context.y + requiredHeight <= PDF_MARGIN_Y_MM + PDF_CONTENT_HEIGHT_MM) { + return + } + + context.pdf.addPage() + context.y = PDF_MARGIN_Y_MM +} + +function wrapPdfText(pdf: PdfJsPDF, text: string, maxWidth: number, style: PdfTextStyle): string[] { + applyPdfTextStyle(pdf, style) + const rawLines = normalizePdfText(text).split('\n') + const lines = rawLines.flatMap((line) => { + if (!line) { + return [''] + } + + const wrapped = pdf.splitTextToSize(line, maxWidth) as string[] + return wrapped.length ? wrapped : [''] + }) + + return lines.length ? lines : [''] +} + +function applyPdfTextStyle(pdf: PdfJsPDF, style: PdfTextStyle): void { + pdf.setFont(PDF_FONT_NAME, 'normal') + pdf.setFontSize(style.fontSize) + pdf.setTextColor(style.color) + pdf.setLineHeightFactor(style.lineHeight / style.fontSize) +} + +function getPdfHeadingStyle(level: number): PdfTextStyle { + if (level <= 1) { + return { fontSize: 19, lineHeight: 9.2, color: PDF_HEADING_COLOR, bold: true } + } + if (level === 2) { + return { fontSize: 15.5, lineHeight: 7.4, color: PDF_HEADING_COLOR, bold: true } + } + return { fontSize: 12.5, lineHeight: 6.4, color: PDF_HEADING_COLOR, bold: true } +} + +function getPdfBodyStyle(overrides: Partial = {}): PdfTextStyle { + return { + fontSize: overrides.fontSize ?? (overrides.code ? 10 : 11), + lineHeight: overrides.lineHeight ?? (overrides.code ? 5.7 : 6.8), + color: overrides.color ?? (overrides.code ? PDF_MUTED_TEXT_COLOR : PDF_TEXT_COLOR), + bold: overrides.bold, + code: overrides.code, + italic: overrides.italic, + } +} + +function getElementPdfText(element: Node): string { + const parts: string[] = [] + collectElementPdfText(element, parts) + return parts.join('').replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+\n/g, '\n').trim() +} + +function collectElementPdfText(node: Node, parts: string[]): void { + if (node.nodeType === Node.TEXT_NODE) { + parts.push(node.textContent ?? '') + return + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return + } + + const element = node as HTMLElement + const tagName = element.tagName.toLowerCase() + + if (tagName === 'img') { + const alt = element.getAttribute('alt') ?? element.getAttribute('title') ?? '' + if (alt) { + parts.push(alt) + } + return + } + + if (tagName === 'br') { + parts.push('\n') + return + } + + if (tagName === 'script' || tagName === 'style') { + return + } + + for (const child of Array.from(element.childNodes)) { + collectElementPdfText(child, parts) + } + + if (['p', 'div', 'section', 'article'].includes(tagName)) { + parts.push('\n') + } +} + +function normalizePdfText(text: string): string { + return text.replace(/\u00a0/g, ' ').replace(/\r\n?/g, '\n').trim() +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + const chunkSize = 0x8000 + let binary = '' + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)) + } + + return window.btoa(binary) +} + +function blobToDataURL(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? '')) + reader.onerror = () => reject(reader.error ?? new Error('blob_read_failed')) + reader.readAsDataURL(blob) + }) } function absolutizeArticleLinks(html: string): string { @@ -1087,352 +1553,6 @@ function absolutizeArticleLinks(html: string): string { return template.innerHTML } -function collectPdfPageBreakAvoidRanges( - renderHost: HTMLElement, - renderScale: number, -): PageBreakAvoidRange[] { - const page = renderHost.querySelector('.article-export-render__page') - - if (!page) { - return [] - } - - const hostRect = renderHost.getBoundingClientRect() - const elements = new Set() - page - .querySelectorAll('img, table, pre, blockquote, figure, .article-editor-image') - .forEach((element) => { - elements.add( - element.closest('.article-editor-image') ?? - element.closest('p, li, table, pre, blockquote, figure') ?? - element, - ) - }) - - const rangePadding = Math.ceil(4 * renderScale) - const maxAvoidHeight = PDF_CONTENT_HEIGHT_PX * renderScale - rangePadding * 2 - const ranges: PageBreakAvoidRange[] = [] - - for (const element of elements) { - const rect = element.getBoundingClientRect() - const start = Math.max(0, Math.floor((rect.top - hostRect.top) * renderScale) - rangePadding) - const end = Math.ceil((rect.bottom - hostRect.top) * renderScale) + rangePadding - - if (end > start && end - start <= maxAvoidHeight) { - ranges.push({ start, end }) - } - } - - return mergePageBreakAvoidRanges(ranges, maxAvoidHeight) -} - -function mergePageBreakAvoidRanges( - ranges: PageBreakAvoidRange[], - maxMergedHeight: number, -): PageBreakAvoidRange[] { - const sorted = [...ranges].sort((a, b) => a.start - b.start) - const merged: PageBreakAvoidRange[] = [] - - for (const range of sorted) { - const previous = merged[merged.length - 1] - if (previous && range.start <= previous.end && range.end - previous.start <= maxMergedHeight) { - previous.end = Math.max(previous.end, range.end) - continue - } - - merged.push({ ...range }) - } - - return merged -} - -function appendCanvasPages( - pdf: jsPDF, - canvas: HTMLCanvasElement, - avoidRanges: PageBreakAvoidRange[], -): void { - const pageHeightPx = Math.floor((canvas.width * PDF_CONTENT_HEIGHT_MM) / PDF_CONTENT_WIDTH_MM) - const pageBreaks = resolveCanvasPageBreaks(canvas, pageHeightPx, avoidRanges) - const pageCanvas = document.createElement('canvas') - const context = pageCanvas.getContext('2d') - - if (!context) { - throw new Error('canvas_context_unavailable') - } - - pageCanvas.width = canvas.width - - for (let pageIndex = 0; pageIndex < pageBreaks.length - 1; pageIndex += 1) { - const sourceY = pageBreaks[pageIndex] ?? 0 - const nextY = pageBreaks[pageIndex + 1] ?? canvas.height - const sliceHeight = Math.max(1, nextY - sourceY) - pageCanvas.height = sliceHeight - context.fillStyle = '#ffffff' - context.fillRect(0, 0, pageCanvas.width, pageCanvas.height) - context.drawImage( - canvas, - 0, - sourceY, - canvas.width, - sliceHeight, - 0, - 0, - canvas.width, - sliceHeight, - ) - - if (pageIndex > 0) { - pdf.addPage() - } - - const pageImageHeightMm = (sliceHeight * PDF_CONTENT_WIDTH_MM) / canvas.width - pdf.addImage( - pageCanvas.toDataURL('image/jpeg', 0.96), - 'JPEG', - PDF_MARGIN_X_MM, - PDF_MARGIN_Y_MM, - PDF_CONTENT_WIDTH_MM, - pageImageHeightMm, - undefined, - 'FAST', - ) - } -} - -function resolveCanvasPageBreaks( - canvas: HTMLCanvasElement, - pageHeightPx: number, - avoidRanges: PageBreakAvoidRange[], -): number[] { - const context = canvas.getContext('2d', { willReadFrequently: true }) - - if (!context) { - const breaks = [0] - for (let y = pageHeightPx; y < canvas.height; y += pageHeightPx) { - breaks.push(y) - } - breaks.push(canvas.height) - return breaks - } - - const rowInkCounts = measureCanvasRowInk(context, canvas) - const breaks = [0] - let currentY = 0 - - while (currentY + pageHeightPx < canvas.height) { - const preferredY = currentY + pageHeightPx - const minY = Math.min( - preferredY - 1, - Math.max(currentY + Math.floor(pageHeightPx * PDF_PAGE_BREAK_MIN_PROGRESS_RATIO), preferredY - PDF_PAGE_BREAK_SEARCH_PX), - ) - const maxY = Math.min(canvas.height - 1, preferredY) - const readableY = findReadablePageBreak(rowInkCounts, minY, maxY) || preferredY - const nextY = avoidSplittingContentBlock( - rowInkCounts, - avoidRanges, - readableY, - currentY, - minY, - maxY, - pageHeightPx, - ) - - if (nextY <= currentY) { - break - } - - breaks.push(nextY) - currentY = nextY - } - - breaks.push(canvas.height) - return breaks -} - -function avoidSplittingContentBlock( - rowInkCounts: number[], - avoidRanges: PageBreakAvoidRange[], - initialY: number, - pageStartY: number, - minY: number, - maxY: number, - pageHeightPx: number, -): number { - let candidate = clampPageBreakY(initialY, minY, maxY) - const pageSafeStartY = pageStartY + 1 - - for (let attempt = 0; attempt < 8; attempt += 1) { - const range = avoidRanges.find((item) => candidate > item.start && candidate < item.end) - if (!range) { - return candidate - } - - const rangeHeight = range.end - range.start - const canFitRangeOnNextPage = rangeHeight < pageHeightPx && range.start > pageSafeStartY - if (canFitRangeOnNextPage && range.end > maxY) { - const beforeRange = findBlankRunCenter( - rowInkCounts, - pageSafeStartY, - Math.min(maxY, range.start), - Math.min(maxY, range.start), - ) - return beforeRange ?? clampPageBreakY(range.start, pageSafeStartY, maxY) - } - - const before = findBlankRunCenter( - rowInkCounts, - pageSafeStartY, - Math.min(maxY, range.start), - Math.min(maxY, range.start), - ) - const after = - range.end <= maxY - ? findBlankRunCenter(rowInkCounts, Math.max(minY, range.end), maxY, Math.max(minY, range.end)) - : null - - if (after !== null && (before === null || Math.abs(maxY - after) < Math.abs(maxY - before))) { - candidate = after - continue - } - - if (before !== null) { - candidate = before - continue - } - - candidate = clampPageBreakY(range.start, minY, maxY) - } - - return candidate -} - -function measureCanvasRowInk(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement): number[] { - const image = context.getImageData(0, 0, canvas.width, canvas.height) - const rowInkCounts = new Array(canvas.height).fill(0) - - for (let y = 0; y < canvas.height; y += 1) { - let ink = 0 - const rowOffset = y * canvas.width * 4 - - for (let x = 0; x < canvas.width; x += 2) { - const offset = rowOffset + x * 4 - const alpha = image.data[offset + 3] ?? 0 - if (alpha <= PDF_PAGE_BREAK_ALPHA_THRESHOLD) { - continue - } - - const red = image.data[offset] ?? 255 - const green = image.data[offset + 1] ?? 255 - const blue = image.data[offset + 2] ?? 255 - - if (red < 248 || green < 248 || blue < 248) { - ink += 1 - } - } - - rowInkCounts[y] = ink - } - - return rowInkCounts -} - -function findReadablePageBreak(rowInkCounts: number[], minY: number, maxY: number): number | null { - const start = Math.max(0, minY) - const end = Math.min(rowInkCounts.length - 1, maxY) - const blankRunCenter = findBlankRunCenter(rowInkCounts, start, end, end) - - if (blankRunCenter !== null) { - return blankRunCenter - } - - let fallbackY = end - let fallbackInk = Number.POSITIVE_INFINITY - for (let y = start; y <= end; y += 1) { - const ink = rowInkCounts[y] ?? Number.POSITIVE_INFINITY - if (ink < fallbackInk) { - fallbackInk = ink - fallbackY = y - } - } - return fallbackY -} - -function findBlankRunCenter( - rowInkCounts: number[], - minY: number, - maxY: number, - targetY: number, -): number | null { - const start = Math.max(0, minY) - const end = Math.min(rowInkCounts.length - 1, maxY) - - if (start > end) { - return null - } - - let bestBlankCenter: number | null = null - let bestBlankDistance = Number.POSITIVE_INFINITY - let blankRunStart: number | null = null - const target = clampPageBreakY(targetY, start, end) - - for (let y = start; y <= end; y += 1) { - const isBlank = (rowInkCounts[y] ?? 0) <= PDF_PAGE_BREAK_INK_THRESHOLD - if (isBlank && blankRunStart === null) { - blankRunStart = y - } - - if ((!isBlank || y === end) && blankRunStart !== null) { - const blankRunEnd = isBlank && y === end ? y : y - 1 - const runLength = blankRunEnd - blankRunStart + 1 - - if (runLength >= PDF_PAGE_BREAK_BLANK_RUN_PX) { - const center = Math.floor((blankRunStart + blankRunEnd) / 2) - const distance = Math.abs(center - target) - if (distance < bestBlankDistance) { - bestBlankCenter = center - bestBlankDistance = distance - } - } - - blankRunStart = null - } - } - - if (bestBlankCenter !== null) { - return bestBlankCenter - } - - return null -} - -function clampPageBreakY(value: number, minY: number, maxY: number): number { - return Math.min(maxY, Math.max(minY, Math.floor(value))) -} - -async function waitForImages(root: HTMLElement): Promise { - const images = Array.from(root.querySelectorAll('img')) - - await Promise.all( - images.map( - (image) => - new Promise((resolve) => { - if (image.complete) { - resolve() - return - } - - image.addEventListener('load', () => resolve(), { once: true }) - image.addEventListener('error', () => resolve(), { once: true }) - }), - ), - ) -} - -function getPdfRenderScale(): number { - const ratio = window.devicePixelRatio || 1 - return Math.min(2, Math.max(1.5, ratio)) -} - function resolveBrowserURL(value: string | null): string { const raw = (value ?? '').trim() diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index 07c21e0..66b0e4c 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -779,11 +779,19 @@ onBeforeUnmount(() => { .article-editor-view__download-menu :deep(.ant-dropdown-menu-item) { min-width: 170px; padding: 11px 14px; - gap: 12px; color: #1f2937; font-size: 15px; } +.article-editor-view__download-menu :deep(.ant-dropdown-menu-title-content) { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + width: 100%; + text-align: left; +} + .article-editor-view__download-icon { display: inline-flex; align-items: center;