diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 228f557..86fda24 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -23,7 +23,10 @@ "@vueuse/core": "^14.2.1", "ant-design-vue": "^4.2.6", "dayjs": "^1.11.20", + "docx": "^9.6.1", "dompurify": "^3.4.2", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.1", "markdown-it": "^14.1.1", "nanoid": "^5.1.7", "pinia": "^3.0.4", diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 1c43c0d..2791403 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -1078,6 +1078,22 @@ const enUS = { noContent: 'This article does not have generated content yet.', editor: { publish: 'Publish', + download: 'Download', + downloadFormats: { + word: 'Word', + pdf: 'PDF', + markdown: 'Markdown', + }, + downloadMessages: { + success: 'Download started.', + failed: 'Export failed. Please try again.', + }, + markdownExportConfirm: { + title: 'Download Markdown', + description: + 'Images will be removed from the Markdown file. Text content such as headings, tables, and links will be kept.', + confirm: 'Download', + }, titlePlaceholder: 'Enter article title', leaveConfirm: { title: 'Unsaved changes', diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 2295a9a..1734012 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -1025,6 +1025,21 @@ const zhCN = { noContent: "当前文章还没有正文内容", editor: { publish: "发布", + download: "下载", + downloadFormats: { + word: "Word", + pdf: "PDF", + markdown: "Markdown", + }, + downloadMessages: { + success: "文件已开始下载", + failed: "导出失败,请稍后重试", + }, + markdownExportConfirm: { + title: "下载 Markdown", + description: "Markdown 文件将去除正文图片,仅保留标题、文字、表格和链接等文本内容。", + confirm: "确认下载", + }, titlePlaceholder: "请输入文章标题", leaveConfirm: { title: "当前文章还没保存", diff --git a/apps/admin-web/src/lib/article-export.ts b/apps/admin-web/src/lib/article-export.ts new file mode 100644 index 0000000..21ca298 --- /dev/null +++ b/apps/admin-web/src/lib/article-export.ts @@ -0,0 +1,1468 @@ +import DOMPurify from 'dompurify' +import MarkdownIt from 'markdown-it' +import { getStoredAccessToken } from './session' +import type { + FileChild as WordFileChild, + ParagraphChild as WordParagraphChild, + TableCell as WordTableCell, +} from 'docx' +import type { jsPDF } from 'jspdf' + +export type ArticleExportFormat = 'markdown' | 'word' | 'pdf' + +type ArticleExportOptions = { + title: string + markdown: string + untitledLabel: string +} + +const markdownRenderer = new MarkdownIt({ + html: true, + linkify: true, + breaks: true, +}) + +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_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 DOCX_PAGE_WIDTH_DXA = 11906 +const DOCX_PAGE_HEIGHT_DXA = 16838 +const DOCX_PAGE_MARGIN_DXA = 1134 +const DOCX_IMAGE_MAX_WIDTH_PX = 620 +const DOCX_IMAGE_MAX_HEIGHT_PX = 760 +const DOCX_TEXT_COLOR = '111827' +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 + children?: MarkdownTokenLike[] | null +} + +type DocxModule = typeof import('docx') + +type WordTextStyle = { + bold?: boolean + italics?: boolean + code?: boolean + color?: string + subScript?: boolean + superScript?: boolean +} + +type WordImageData = { + type: 'jpg' | 'png' | 'gif' | 'bmp' + data: ArrayBuffer + width: number + height: number + alt: string +} + +export function hasArticleMarkdownImages(markdown: string): boolean { + return markdownTokensContainImage(markdownRenderer.parse(markdown || '', {})) +} + +export async function downloadArticleDocument( + format: Exclude, + options: ArticleExportOptions, +): Promise { + const title = normalizeTitle(options.title) + const filenameBase = buildFilename(title || options.untitledLabel) + + if (format === 'markdown') { + downloadBlob( + new Blob([buildMarkdownDocument(title, options.markdown)], { + type: 'text/markdown;charset=utf-8', + }), + `${filenameBase}.md`, + ) + return + } + + downloadBlob(await buildWordDocumentBlob(title, options.markdown, options.untitledLabel), `${filenameBase}.docx`) +} + +export async function downloadArticlePdf(options: ArticleExportOptions): Promise { + const [{ default: html2canvas }, { jsPDF }] = await Promise.all([ + import('html2canvas'), + import('jspdf'), + ]) + const title = normalizeTitle(options.title) + const filenameBase = buildFilename(title || options.untitledLabel) + const renderHost = createPdfRenderHost(title, options.markdown) + + 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, + }) + + 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() + } +} + +function buildMarkdownDocument(title: string, markdown: string): string { + const body = stripMarkdownImages(markdown).replace(/^\uFEFF/, '').trim() + + if (!title) { + return ensureTrailingNewline(body) + } + + if (body.match(new RegExp(`^#\\s+${escapeRegExp(title)}\\s*(\\r?\\n|$)`))) { + return ensureTrailingNewline(body) + } + + return ensureTrailingNewline(`# ${title}\n\n${body}`) +} + +function stripMarkdownImages(markdown: string): string { + const imageReferenceLabels = collectMarkdownImageReferenceLabels(markdown) + const withoutHtmlImageBlocks = markdown + .replace( + /]*class=(?:"[^"]*\barticle-editor-image\b[^"]*"|'[^']*\barticle-editor-image\b[^']*')[\s\S]*?<\/p>/gi, + '', + ) + .replace(/]*>/gi, '') + + return withoutHtmlImageBlocks + .replace(/!\[[^\]\r\n]*\]\([^)]+?\)/g, '') + .replace(/!\[[^\]\r\n]*\]\[[^\]\r\n]+\]/g, '') + .replace(/!\[([^\]\r\n]+)\]\[\]/g, '') + .replace(/!\[([^\]\r\n]+)\](?![\[(])/g, '') + .replace(/^[ \t]{0,3}\[([^\]\r\n]+)\]:[^\n]*(?:\n[ \t]+[^\n]*)*/gm, (match, label) => + imageReferenceLabels.has(normalizeMarkdownReferenceLabel(String(label))) ? '' : match, + ) + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') +} + +function collectMarkdownImageReferenceLabels(markdown: string): Set { + const labels = new Set() + + for (const match of markdown.matchAll(/!\[[^\]\r\n]*\]\[([^\]\r\n]+)\]/g)) { + labels.add(normalizeMarkdownReferenceLabel(match[1] ?? '')) + } + + for (const match of markdown.matchAll(/!\[([^\]\r\n]+)\]\[\]/g)) { + labels.add(normalizeMarkdownReferenceLabel(match[1] ?? '')) + } + + for (const match of markdown.matchAll(/!\[([^\]\r\n]+)\](?![\[(])/g)) { + labels.add(normalizeMarkdownReferenceLabel(match[1] ?? '')) + } + + for (const token of markdownRenderer.parse(markdown || '', {})) { + collectImageReferenceLabelsFromToken(token, labels) + } + + return labels +} + +function collectImageReferenceLabelsFromToken(token: MarkdownTokenLike, labels: Set): void { + if (token.type === 'image') { + const label = getMarkdownTokenAttribute(token, 'label') || getMarkdownTokenAttribute(token, 'alt') + if (label) { + labels.add(normalizeMarkdownReferenceLabel(label)) + } + } + + token.children?.forEach((child) => collectImageReferenceLabelsFromToken(child, labels)) +} + +function markdownTokensContainImage(tokens: MarkdownTokenLike[]): boolean { + return tokens.some((token) => { + if (token.type === 'image') { + return true + } + + if ( + (token.type === 'html_block' || token.type === 'html_inline') && + / | null }).attrs + const attr = attrs?.find(([key]) => key === name) + return attr?.[1] ?? '' +} + +function normalizeMarkdownReferenceLabel(label: string): string { + return label.trim().replace(/\s+/g, ' ').toLowerCase() +} + +async function buildWordDocumentBlob( + title: string, + markdown: string, + untitledLabel: string, +): Promise { + const docx = await import('docx') + const documentTitle = title || untitledLabel + const children = await buildWordChildren(docx, title, markdown) + const document = new docx.Document({ + creator: '省心推', + title: documentTitle, + numbering: { + config: [ + { + reference: 'article-bullet', + levels: [ + { + level: 0, + format: docx.LevelFormat.BULLET, + text: '•', + style: { + paragraph: { + indent: { left: 480, hanging: 240 }, + }, + }, + }, + { + level: 1, + format: docx.LevelFormat.BULLET, + text: '◦', + style: { + paragraph: { + indent: { left: 840, hanging: 240 }, + }, + }, + }, + ], + }, + { + reference: 'article-number', + levels: [ + { + level: 0, + format: docx.LevelFormat.DECIMAL, + text: '%1.', + style: { + paragraph: { + indent: { left: 520, hanging: 280 }, + }, + }, + }, + { + level: 1, + format: docx.LevelFormat.LOWER_LETTER, + text: '%2.', + style: { + paragraph: { + indent: { left: 900, hanging: 280 }, + }, + }, + }, + ], + }, + ], + }, + styles: { + default: { + document: { + run: { + font: 'Microsoft YaHei', + size: 22, + color: DOCX_TEXT_COLOR, + }, + paragraph: { + spacing: { after: 160, line: 360 }, + }, + }, + heading1: { + run: { + bold: true, + color: '101828', + size: 36, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 260, after: 180 }, + keepNext: true, + }, + }, + heading2: { + run: { + bold: true, + color: '101828', + size: 30, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 240, after: 140 }, + keepNext: true, + }, + }, + heading3: { + run: { + bold: true, + color: '101828', + size: 25, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { before: 200, after: 120 }, + keepNext: true, + }, + }, + hyperlink: { + run: { + color: DOCX_LINK_COLOR, + underline: {}, + }, + }, + }, + paragraphStyles: [ + { + id: 'ArticleTitle', + name: 'Article Title', + run: { + bold: true, + color: '101828', + size: 44, + font: 'Microsoft YaHei', + }, + paragraph: { + spacing: { after: 260 }, + keepNext: true, + }, + }, + ], + }, + sections: [ + { + properties: { + page: { + size: { + width: DOCX_PAGE_WIDTH_DXA, + height: DOCX_PAGE_HEIGHT_DXA, + }, + margin: { + top: DOCX_PAGE_MARGIN_DXA, + right: DOCX_PAGE_MARGIN_DXA, + bottom: DOCX_PAGE_MARGIN_DXA, + left: DOCX_PAGE_MARGIN_DXA, + }, + }, + }, + children, + }, + ], + }) + + return docx.Packer.toBlob(document) +} + +async function buildWordChildren( + docx: DocxModule, + title: string, + markdown: string, +): Promise { + const template = document.createElement('template') + template.innerHTML = buildArticleHtml(title, markdown) + const children: WordFileChild[] = [] + + for (const child of Array.from(template.content.children)) { + const built = await buildWordBlock( + docx, + child, + null, + children.length === 0 && + child.tagName.toLowerCase() === 'h1' && + normalizeTitle(child.textContent ?? '') === title, + ) + children.push(...built) + } + + if (!children.length) { + children.push(new docx.Paragraph('')) + } + + return children +} + +async function buildWordBlock( + docx: DocxModule, + element: Element, + listState: { ordered: boolean; level: number } | null = null, + isTitleHeading = false, +): Promise { + const tagName = element.tagName.toLowerCase() + + if (isTitleHeading) { + return [ + new docx.Paragraph({ + style: 'ArticleTitle', + children: buildWordInlineChildren(docx, element), + }), + ] + } + + if (tagName === 'h1' || tagName === 'h2' || tagName === 'h3') { + return [ + new docx.Paragraph({ + heading: + tagName === 'h1' + ? docx.HeadingLevel.HEADING_1 + : tagName === 'h2' + ? docx.HeadingLevel.HEADING_2 + : docx.HeadingLevel.HEADING_3, + children: buildWordInlineChildren(docx, element), + }), + ] + } + + if (tagName === 'h4' || tagName === 'h5' || tagName === 'h6') { + return [ + new docx.Paragraph({ + heading: docx.HeadingLevel.HEADING_3, + children: buildWordInlineChildren(docx, element), + }), + ] + } + + if (tagName === 'ul' || tagName === 'ol') { + return buildWordList(docx, element, tagName === 'ol', listState?.level ?? 0) + } + + if (tagName === 'table') { + return [buildWordTable(docx, element)] + } + + if (tagName === 'blockquote') { + const paragraphs = await buildWordContainerBlocks(docx, element) + return paragraphs.map((block) => { + if (!(block instanceof docx.Paragraph)) { + return block + } + + return new docx.Paragraph({ + children: buildWordInlineChildren(docx, element, { color: DOCX_MUTED_TEXT_COLOR }), + border: { + left: { + color: DOCX_BORDER_COLOR, + size: 18, + space: 12, + style: docx.BorderStyle.SINGLE, + }, + }, + spacing: { before: 80, after: 180 }, + }) + }) + } + + if (tagName === 'pre') { + return [ + new docx.Paragraph({ + children: [ + new docx.TextRun({ + text: element.textContent ?? '', + font: 'Consolas', + size: 19, + color: 'F9FAFB', + }), + ], + shading: { fill: '111827', type: docx.ShadingType.CLEAR }, + spacing: { before: 80, after: 180 }, + }), + ] + } + + if (tagName === 'hr') { + return [ + new docx.Paragraph({ + border: { + bottom: { + color: 'E5E7EB', + size: 8, + space: 4, + style: docx.BorderStyle.SINGLE, + }, + }, + spacing: { before: 140, after: 140 }, + }), + ] + } + + if (tagName === 'p' && element.querySelector(':scope > img') && !getDirectText(element).trim()) { + const imageParagraph = await buildWordImageParagraph(docx, element.querySelector('img')) + return imageParagraph ? [imageParagraph] : [] + } + + if (tagName === 'p' || tagName === 'div' || tagName === 'section' || tagName === 'article') { + return [ + new docx.Paragraph({ + children: buildWordInlineChildren(docx, element), + numbering: listState + ? { reference: listState.ordered ? 'article-number' : 'article-bullet', level: listState.level } + : undefined, + spacing: { after: listState ? 120 : 160, line: 360 }, + }), + ] + } + + const paragraphChildren = buildWordInlineChildren(docx, element) + return paragraphChildren.length + ? [ + new docx.Paragraph({ + children: paragraphChildren, + spacing: { after: 160, line: 360 }, + }), + ] + : [] +} + +async function buildWordContainerBlocks( + docx: DocxModule, + element: Element, +): Promise { + const children: WordFileChild[] = [] + + if (element.children.length === 0) { + return [ + new docx.Paragraph({ + children: buildWordInlineChildren(docx, element), + }), + ] + } + + for (const child of Array.from(element.children)) { + children.push(...(await buildWordBlock(docx, child))) + } + + return children +} + +async function buildWordList( + docx: DocxModule, + element: Element, + ordered: boolean, + level: number, +): Promise { + const children: WordFileChild[] = [] + + 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))) + const inlineRuns = buildWordInlineChildren(docx, itemContainer) + + children.push( + new docx.Paragraph({ + children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')], + numbering: { + reference: ordered ? 'article-number' : 'article-bullet', + level: Math.min(level, 1), + }, + spacing: { after: 100, line: 340 }, + }), + ) + + for (const nestedList of nestedLists) { + children.push( + ...(await buildWordList(docx, nestedList, nestedList.tagName.toLowerCase() === 'ol', level + 1)), + ) + } + } + + return children +} + +function buildWordTable(docx: DocxModule, element: Element): InstanceType { + const rows = Array.from(element.querySelectorAll('tr')).map((row) => { + const cells = Array.from(row.children).filter((cell) => ['td', 'th'].includes(cell.tagName.toLowerCase())) + return new docx.TableRow({ + cantSplit: true, + tableHeader: cells.some((cell) => cell.tagName.toLowerCase() === 'th'), + children: cells.map((cell) => buildWordTableCell(docx, cell)), + }) + }) + + return new docx.Table({ + rows: rows.length ? rows : [new docx.TableRow({ children: [emptyWordTableCell(docx)] })], + width: { size: 100, type: docx.WidthType.PERCENTAGE }, + layout: docx.TableLayoutType.AUTOFIT, + borders: { + top: wordTableBorder(docx), + bottom: wordTableBorder(docx), + left: wordTableBorder(docx), + right: wordTableBorder(docx), + insideHorizontal: wordTableBorder(docx), + insideVertical: wordTableBorder(docx), + }, + margins: { + top: 100, + bottom: 100, + left: 140, + right: 140, + }, + }) +} + +function buildWordTableCell(docx: DocxModule, cell: Element): WordTableCell { + const isHeader = cell.tagName.toLowerCase() === 'th' + const inlineRuns = buildWordInlineChildren(docx, cell, { bold: isHeader }) + return new docx.TableCell({ + children: [ + new docx.Paragraph({ + children: inlineRuns.length ? inlineRuns : [new docx.TextRun('')], + spacing: { after: 0, line: 300 }, + }), + ], + shading: isHeader ? { fill: 'F2F4F7', type: docx.ShadingType.CLEAR } : undefined, + verticalAlign: docx.VerticalAlignTable.CENTER, + }) +} + +function emptyWordTableCell(docx: DocxModule): WordTableCell { + return new docx.TableCell({ children: [new docx.Paragraph('')] }) +} + +function wordTableBorder(docx: DocxModule) { + return { + color: DOCX_BORDER_COLOR, + size: 6, + style: docx.BorderStyle.SINGLE, + } +} + +function buildWordInlineChildren( + docx: DocxModule, + element: Node, + inheritedStyle: WordTextStyle = {}, +): WordParagraphChild[] { + const children: WordParagraphChild[] = [] + + for (const child of Array.from(element.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? '' + if (text) { + children.push(buildWordTextRun(docx, text, inheritedStyle)) + } + continue + } + + if (child.nodeType !== Node.ELEMENT_NODE) { + continue + } + + const childElement = child as HTMLElement + const tagName = childElement.tagName.toLowerCase() + const nextStyle = { ...inheritedStyle } + + if (tagName === 'strong' || tagName === 'b') { + nextStyle.bold = true + } + if (tagName === 'em' || tagName === 'i') { + nextStyle.italics = true + } + if (tagName === 'code') { + nextStyle.code = true + } + if (tagName === 'sub') { + nextStyle.subScript = true + } + if (tagName === 'sup') { + nextStyle.superScript = true + } + if (tagName === 'br') { + children.push(new docx.TextRun({ text: '', break: 1 })) + continue + } + if (tagName === 'img') { + continue + } + if (tagName === 'a') { + const href = childElement.getAttribute('href')?.trim() + const linkChildren = buildWordInlineChildren(docx, childElement, { + ...nextStyle, + color: DOCX_LINK_COLOR, + }) + + if (href && /^https?:\/\//i.test(href)) { + children.push(new docx.ExternalHyperlink({ link: href, children: linkChildren })) + } else { + children.push(...linkChildren) + } + continue + } + + children.push(...buildWordInlineChildren(docx, childElement, nextStyle)) + } + + return children +} + +function buildWordTextRun(docx: DocxModule, text: string, style: WordTextStyle): WordParagraphChild { + return new docx.TextRun({ + text: normalizeWordText(text), + bold: style.bold, + italics: style.italics, + color: style.color ?? (style.code ? '344054' : DOCX_TEXT_COLOR), + font: style.code ? 'Consolas' : 'Microsoft YaHei', + size: style.code ? 20 : 22, + subScript: style.subScript, + superScript: style.superScript, + shading: style.code ? { fill: 'F3F4F6', type: docx.ShadingType.CLEAR } : undefined, + }) +} + +async function buildWordImageParagraph( + docx: DocxModule, + image: HTMLImageElement | null, +): Promise | null> { + const imageData = await loadWordImageData(image) + if (!imageData) { + return null + } + + const size = fitImageSize(imageData.width, imageData.height, DOCX_IMAGE_MAX_WIDTH_PX, DOCX_IMAGE_MAX_HEIGHT_PX) + return new docx.Paragraph({ + alignment: docx.AlignmentType.CENTER, + children: [ + new docx.ImageRun({ + type: imageData.type, + data: imageData.data, + transformation: { + width: size.width, + height: size.height, + }, + altText: { + name: imageData.alt || 'article image', + title: imageData.alt || 'article image', + }, + }), + ], + spacing: { before: 100, after: 180 }, + keepLines: true, + }) +} + +async function loadWordImageData(image: HTMLImageElement | null): Promise { + const src = image?.getAttribute('src') + if (!image || !src) { + return null + } + + const resolvedSrc = resolveBrowserURL(src) + const alt = image.getAttribute('alt') ?? image.getAttribute('title') ?? '' + const fallbackWidth = Number.parseInt(image.getAttribute('width') ?? '', 10) || DOCX_IMAGE_MAX_WIDTH_PX + const fallbackHeight = Number.parseInt(image.getAttribute('height') ?? '', 10) || 360 + + try { + const response = await fetch(resolvedSrc, buildWordImageFetchInit(resolvedSrc)) + if (!response.ok) { + return null + } + + const contentType = response.headers.get('content-type') + const data = await response.arrayBuffer() + const dimensions = await readBrowserImageSize(resolvedSrc) + const type = inferWordImageType(resolvedSrc, contentType) + if (!type) { + const converted = await convertImageBytesToPng(data, contentType) + return converted + ? { + ...converted, + alt, + } + : null + } + + return { + type, + data, + width: dimensions.width || fallbackWidth, + height: dimensions.height || fallbackHeight, + alt, + } + } catch { + const converted = await convertImageElementToPng(image) + return converted + ? { + ...converted, + alt, + } + : null + } +} + +function buildWordImageFetchInit(src: string): RequestInit { + const headers = new Headers() + + try { + const url = new URL(src) + const token = getStoredAccessToken() + if (token && url.pathname.startsWith('/api/')) { + headers.set('Authorization', `Bearer ${token}`) + } + } catch { + // ignore malformed image URLs; fetch will surface the actual failure. + } + + return { + credentials: 'include', + headers: headersHasEntries(headers) ? headers : undefined, + } +} + +function headersHasEntries(headers: Headers): boolean { + for (const _entry of headers) { + return true + } + return false +} + +function inferWordImageType(src: string, contentType?: string | null): WordImageData['type'] | null { + const mediaTypeMatch = src.match(/^data:image\/(png|jpe?g|gif|bmp);/i) + const extensionMatch = src.split(/[?#]/, 1)[0]?.match(/\.(png|jpe?g|gif|bmp)$/i) + const contentTypeMatch = contentType?.match(/^image\/(png|jpe?g|gif|bmp)/i) + const rawType = (mediaTypeMatch?.[1] ?? extensionMatch?.[1] ?? contentTypeMatch?.[1] ?? '').toLowerCase() + + if (rawType === 'jpg' || rawType === 'jpeg') { + return 'jpg' + } + if (rawType === 'png' || rawType === 'gif' || rawType === 'bmp') { + return rawType + } + return null +} + +async function convertImageBytesToPng( + data: ArrayBuffer, + contentType?: string | null, +): Promise | null> { + const blob = new Blob([data], { type: contentType || 'application/octet-stream' }) + const objectUrl = URL.createObjectURL(blob) + + try { + return await convertImageSourceToPng(objectUrl) + } finally { + URL.revokeObjectURL(objectUrl) + } +} + +async function convertImageElementToPng( + image: HTMLImageElement, +): Promise | null> { + if (!image.complete || !image.naturalWidth || !image.naturalHeight) { + return null + } + + try { + return await drawImageToPng(image) + } catch { + return null + } +} + +function convertImageSourceToPng(src: string): Promise | null> { + return new Promise((resolve) => { + const image = new Image() + image.onload = () => { + drawImageToPng(image).then(resolve).catch(() => resolve(null)) + } + image.onerror = () => resolve(null) + image.src = src + }) +} + +function drawImageToPng(image: HTMLImageElement): Promise> { + return new Promise((resolve, reject) => { + 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) { + reject(new Error('canvas_context_unavailable')) + return + } + + context.drawImage(image, 0, 0, canvas.width, canvas.height) + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error('image_png_conversion_failed')) + return + } + + blob + .arrayBuffer() + .then((data) => + resolve({ + type: 'png', + data, + width: canvas.width, + height: canvas.height, + }), + ) + .catch(reject) + }, 'image/png') + }) +} + +function readBrowserImageSize(src: string): Promise<{ width: number; height: number }> { + return new Promise((resolve) => { + const image = new Image() + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }) + image.onerror = () => resolve({ width: 0, height: 0 }) + image.src = src + }) +} + +function fitImageSize( + width: number, + height: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } { + const safeWidth = Math.max(1, width) + const safeHeight = Math.max(1, height) + const ratio = Math.min(maxWidth / safeWidth, maxHeight / safeHeight, 1) + return { + width: Math.round(safeWidth * ratio), + height: Math.round(safeHeight * ratio), + } +} + +function normalizeWordText(text: string): string { + return text.replace(/\s+/g, ' ') +} + +function getDirectText(element: Element): string { + return Array.from(element.childNodes) + .filter((child) => child.nodeType === Node.TEXT_NODE) + .map((child) => child.textContent ?? '') + .join('') +} + +function buildArticleHtml(title: string, markdown: string): string { + const rawBody = markdownRenderer.render(markdown || '') + const titleHtml = title ? `

${escapeHtml(title)}

` : '' + 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 +} + +function absolutizeArticleLinks(html: string): string { + const template = document.createElement('template') + template.innerHTML = html + + for (const image of template.content.querySelectorAll('img[src]')) { + const resolved = resolveBrowserURL(image.getAttribute('src')) + image.src = resolved + if (/^https?:\/\//i.test(resolved)) { + image.setAttribute('crossorigin', 'anonymous') + } + } + + for (const anchor of template.content.querySelectorAll('a[href]')) { + anchor.href = resolveBrowserURL(anchor.getAttribute('href')) + } + + 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() + + if (!raw || /^(data:|blob:|mailto:|tel:|#)/i.test(raw)) { + return raw + } + + try { + return new URL(raw, window.location.href).toString() + } catch { + return raw + } +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.rel = 'noopener' + document.body.append(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +function buildFilename(title: string): string { + const normalized = title + .replace(/[\\/:*?"<>|]+/g, '-') + .replace(/[\u0000-\u001f\u007f]+/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/[. ]+$/g, '') + + return (normalized || 'article').slice(0, 80) +} + +function normalizeTitle(title: string): string { + return title.replace(/\s+/g, ' ').trim() +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith('\n') ? value : `${value}\n` +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index 236b362..07c21e0 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -1,6 +1,11 @@