diff --git a/assets/webui/index.html b/assets/webui/index.html index 0a7067e..4f9927c 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -93,7 +93,8 @@
- + + 擦除
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 6fa0961..c2f7e1a 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -112,6 +112,7 @@ a:focus-visible { } .editor-file, +.editor-erase, .editor-paragraph, .editor-image, .editor-group:has(#textFont), @@ -119,11 +120,15 @@ a:focus-visible { grid-template-columns: repeat(2, max-content); } -.editor-transform, -.editor-erase { +.editor-transform { grid-template-columns: max-content; } +#eraseObjectButton { + grid-row: span 2; + height: 100%; +} + .editor-image > select { grid-column: 1 / -1; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 9bc9110..adaa81f 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -120,6 +120,7 @@ const el = { drawEllipseButton: document.querySelector("#drawEllipseButton"), eraseObjectButton: document.querySelector("#eraseObjectButton"), eraseRegionButton: document.querySelector("#eraseRegionButton"), + erasePathButton: document.querySelector("#erasePathButton"), shapeFill: document.querySelector("#shapeFill"), shapeFillColor: document.querySelector("#shapeFillColor"), shapeStroke: document.querySelector("#shapeStroke"), @@ -277,10 +278,11 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); }, onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), - onErase: (items, box) => { + onErase: (items, box, points) => { const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete")); if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; } const item = { index: items[0].index, id: items.map(item => item.id) }; + if (points) return changeDocument("ofdgoEraseObjectsPath", item, points); return box ? changeDocument("ofdgoEraseObjects", item, box.x, box.y, box.width, box.height) : changeDocument("ofdgoDeleteObjects", item); }, @@ -437,7 +439,7 @@ el.resetCropButton.addEventListener("click", () => { editorClick(el.undoButton, () => changeDocument("ofdgoUndo")); editorClick(el.redoButton, () => changeDocument("ofdgoRedo")); for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"], - [el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"]]) { + [el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"], [el.erasePathButton, "erase-path"]]) { editorClick(button, () => { if (!state.editing || document.body.hasAttribute("aria-busy")) { return; @@ -976,7 +978,7 @@ async function beginCanvasText(index, box, surface, page) { const openSeq = state.openSeq; setBusy(true); try { - const data = font.embedded ? (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes : await fontManager.read(font); + const data = await readTextFont(font); const face = new FontFace("ofdgo-draft", data); await face.load(); if (openSeq !== state.openSeq || !surface.isConnected) return; @@ -991,7 +993,7 @@ async function beginCanvasText(index, box, surface, page) { } function editorFonts(item) { - const fonts = [...fontManager.userFonts.filter((font) => font.enabled), ...fontManager.catalog]; + const fonts = fontManager.editorFonts(); if (item) { fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "update") }); } @@ -999,22 +1001,31 @@ function editorFonts(item) { } async function loadEditorFonts() { - if (!fontManager.canReadLocal() || fontManager.catalogLoaded || fontManager.permission === "denied" || state.fontCatalogLoading) return; + const local = fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied"; + if (state.fontCatalogLoading || !local && !fontManager.userFonts.some(font => font.enabled && !font.faces)) return; state.fontCatalogLoading = true; try { - await fontManager.queryLocal(); + if (local) { + try { await fontManager.queryLocal(); } + catch (err) { setStatus(err?.name === "NotAllowedError" ? "字体尚未授权" : String(err.message || err)); } + } + await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data)); const open = fontPicker.open; const selected = state.textFonts[Number(fontPicker.value)]; const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value; updateTextFonts(canvasEditor.selected, true); if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query); } - } catch (err) { - setStatus(err?.name === "NotAllowedError" ? "字体尚未授权" : String(err.message || err)); } finally { state.fontCatalogLoading = false; } } +async function readTextFont(font) { + if (font.embedded) return (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes; + if (font.file) return (await callWASM("ofdgoFontFace", await fontManager.read(font.file), font.index)).bytes; + return fontManager.read(font); +} + function updateTextFonts(item, refresh = false) { const id = item?.type === "TextObject" && !item.draft ? item.font : null; if (refresh || state.textFontID !== id) { @@ -1039,7 +1050,7 @@ async function changeTextFont() { const openSeq = state.openSeq; setBusy(true); try { - const data = font.embedded ? (await callWASM("ofdgoEditorFont", font.id.slice(9))).bytes : await fontManager.read(font); + const data = await readTextFont(font); const face = new FontFace("ofdgo-edit-font", data); await face.load(); if (openSeq !== state.openSeq || canvasEditor.input !== editing) return; @@ -1056,7 +1067,7 @@ async function changeTextFont() { } } else if (item?.type === "TextObject") { if (!font.embedded && canEditObject(item, "replaceFont")) { - await changeDocument("ofdgoUpdateText", item, canEditObject(item, "layoutKnown") ? item.text : null, fontManager.read(font), item.size, null); + await changeDocument("ofdgoUpdateText", item, canEditObject(item, "layoutKnown") ? item.text : null, readTextFont(font), item.size, null); updateTextFonts(canvasEditor.selected, true); } } else { @@ -1221,7 +1232,7 @@ function updateDrawingControls() { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); } - for (const [button, name] of [[el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"]]) { + for (const [button, name] of [[el.eraseObjectButton, "erase-object"], [el.eraseRegionButton, "erase-region"], [el.erasePathButton, "erase-path"]]) { button.disabled = disabled; button.setAttribute("aria-pressed", String(tool === name)); } @@ -1256,7 +1267,7 @@ async function changeDocument(name, item, ...args) { return true; } setEditorInfo(doc); - if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects") { + if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects" || name === "ofdgoEraseObjectsPath") { canvasEditor.clear(); } if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { @@ -3021,7 +3032,7 @@ async function pasteEditorContent(event) { return; } const style = currentTextStyle(); - const data = font.embedded ? callWASM("ofdgoEditorFont", font.id.slice(9)).then(result => result.bytes) : fontManager.read(font); + const data = readTextFont(font); await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color, page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0); } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 0ba3e9c..fcb29f4 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -505,7 +505,7 @@ export class CanvasEditor { ? drag.items.filter(item => item.x < drag.box.x + drag.box.width && item.x + item.width > drag.box.x && item.y < drag.box.y + drag.box.height && item.y + item.height > drag.box.y) : []; this.cancel(); - if (items.length) this.options.onErase(items, drag.erase === "erase-region" ? drag.box : null); + if (items.length && (!drag.points || drag.points.length >= 3)) this.options.onErase(items, drag.box, drag.points); return; } if (drag.crop) { @@ -591,6 +591,11 @@ export class CanvasEditor { rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle", { fill: true, fillColor: "var(--accent-soft)", stroke: true, strokeColor: "var(--accent)", lineWidth: 0.2 })); + if (this.tool === "erase-path") { + Object.assign(this.drag, this.createPreview(surface, page, "path", + { fill: true, fillColor: "var(--accent-soft)", stroke: true, strokeColor: "var(--accent)", lineWidth: 0.2 }), { points: [] }); + this.drag.node.setAttribute("fill-rule", "evenodd"); + } this.viewer.setPointerCapture(event.pointerId); this.moveErase(event, this.drag); } @@ -598,6 +603,18 @@ export class CanvasEditor { moveErase(event, drag) { if (drag.erase === "erase-region") { this.moveShape(event, drag); return; } const point = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); + if (drag.points) { + const last = drag.lastPointer; + if (last && Math.hypot(event.clientX - last.x, event.clientY - last.y) < 2) return; + drag.lastPointer = { x: event.clientX, y: event.clientY }; + drag.points.push(point); + const box = drag.box || { x: point.x, y: point.y, width: 0, height: 0 }; + const right = Math.max(box.x + box.width, point.x), bottom = Math.max(box.y + box.height, point.y); + drag.box = { x: Math.min(box.x, point.x), y: Math.min(box.y, point.y) }; + Object.assign(drag.box, { width: right - drag.box.x, height: bottom - drag.box.y }); + drag.node.setAttribute("d", drag.points.map((point, index) => `${index ? "L" : "M"}${point.x} ${point.y}`).join(" ") + " Z"); + return; + } const item = [...drag.items].reverse().find(item => point.x >= item.x && point.x <= item.x + item.width && point.y >= item.y && point.y <= item.y + item.height); if (item) { drag.erased.add(item); item.node.classList.add("erasing"); } diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index a69fbad..c40325d 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -48,7 +48,7 @@ export class FontPicker { const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); const terms = normalize(query).split(" ").filter(Boolean); this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => { - const name = normalize([font.fullName, font.name, font.family, font.postscriptName].filter(Boolean).join(" ")); + const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" ")); return terms.every(term => name.includes(term)); }); this.list.replaceChildren(); @@ -105,8 +105,10 @@ export class FontPicker { commitExact() { const value = this.input.value.trim().toLocaleLowerCase(); - const index = this.fonts.findIndex(font => !font.disabled - && [font.fullName, font.name, font.postscriptName].some(name => name?.toLocaleLowerCase() === value)); + const matches = font => font && !font.disabled + && [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value); + const selected = Number(this.value); + const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches); if (index >= 0) this.choose(index); } @@ -283,6 +285,22 @@ export class FontManager { return font.data; } + editorFonts() { + return [...this.userFonts.filter(font => font.enabled).flatMap(file => { + if (!file.faces) return [file]; + if (!file.faces.length) return [{ ...file, disabled: true }]; + return file.faces.map(face => ({ ...face, id: face.index ? `${file.id}:${face.index}` : file.id, name: file.name, file })); + }), ...this.catalog]; + } + + async loadFaces(inspect) { + for (const file of this.userFonts) { + if (!file.enabled || file.faces) continue; + try { file.faces = await inspect(await this.read(file)); } + catch { file.faces = []; } + } + } + async add(fonts) { let saved = true; try { @@ -320,6 +338,7 @@ export class FontManager { const fonts = stored.map((font) => ({ ...font, data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data, + faces: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).faces : undefined, source: "stored", })); fonts.push(...this.userFonts.filter((font) => font.source === "upload")); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 7d6873a..2c3f2a5 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -97,6 +97,8 @@ func RunWASM() { registerExportCallback("ofdgoExportDocument", exportDocument) registerExportCallback("ofdgoExportAttachment", exportAttachment) registerCallback("ofdgoMatchFontFiles", matchFontFiles) + registerCallback("ofdgoFontFaces", fontFaces) + registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoCreateDocument", createDocument) registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoChangePage", changePage) @@ -115,6 +117,7 @@ func RunWASM() { registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) registerCallback("ofdgoEraseObjects", eraseObjects) + registerCallback("ofdgoEraseObjectsPath", eraseObjectsPath) registerCallback("ofdgoCopyObjects", copyObjects) registerCallback("ofdgoCaptureObjects", captureObjects) registerCallback("ofdgoPasteObjects", pasteObjects) @@ -467,6 +470,32 @@ func matchFontFiles(args []js.Value) (any, error) { return currentSession.Reader.MatchFontFiles(stringsFromJS(args[0])) } +// fontFaces 读取上传字体的名称和集合索引 +// 入参: args 字体数据 +// 返回: any 字体列表, error 错误信息 +func fontFaces(args []js.Value) (any, error) { + data, err := bytesFromJS(args[0]) + if err != nil { + return nil, err + } + return (ofdgo.FontFile{Data: data}).Faces() +} + +// fontFace 提取编辑时选定的字体,供输入预览和保存共用 +// 入参: args 字体数据和零起始索引 +// 返回: any 独立字体数据, error 错误信息 +func fontFace(args []js.Value) (any, error) { + data, err := bytesFromJS(args[0]) + if err != nil { + return nil, err + } + data, err = (ofdgo.FontFile{Data: data}).Face(args[1].Int()) + if err != nil { + return nil, err + } + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil +} + // bytesFromJS 从浏览器值读取二进制数据 // 入参: value 浏览器值 // 返回: []byte 二进制数据, error 错误信息 @@ -535,13 +564,13 @@ func encodeResult(result apiResult) string { // currentEditor 当前编辑文档 var currentEditor *ofdgo.Editor -// editorClipboard 当前编辑文档中的对象快照与剪贴板标识。 +// editorClipboard 当前编辑文档中的对象快照与剪贴板标识 type editorClipboard struct { token string objects []ofdgo.GraphicObject } -// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据。 +// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 var copiedObjects *editorClipboard // editorInfo 编辑文档信息与操作状态 @@ -560,7 +589,7 @@ type editorPageInfo struct { PageIndex int `json:"pageIndex"` } -// editorSelectionInfo 对象复制结果及新选区。 +// editorSelectionInfo 对象复制结果及新选区 type editorSelectionInfo struct { editorInfo SelectedIDs []string `json:"selectedIDs"` @@ -851,7 +880,7 @@ func copyObjects(args []js.Value) (any, error) { return copyEditorObjects(args[0].Int(), objects, args[2].Float(), args[3].Float()) } -// captureObjects 保存独立选区快照,不修改文档或历史。 +// captureObjects 保存独立选区快照,不修改文档或历史 // 入参: args 页面索引、对象标识数组和剪贴板标识 // 返回: any 空结果, error 错误信息 func captureObjects(args []js.Value) (any, error) { @@ -866,7 +895,7 @@ func captureObjects(args []js.Value) (any, error) { return nil, nil } -// pasteObjects 将当前文档的对象快照粘贴到目标页。 +// pasteObjects 将当前文档的对象快照粘贴到目标页 // 入参: args 目标页、剪贴板标识和横纵位移 // 返回: any 文档信息及新选区, error 错误信息 func pasteObjects(args []js.Value) (any, error) { @@ -876,7 +905,7 @@ func pasteObjects(args []js.Value) (any, error) { return copyEditorObjects(args[0].Int(), copiedObjects.objects, args[2].Float(), args[3].Float()) } -// copyEditorObjects 复制快照并更新预览,返回新对象标识。 +// copyEditorObjects 复制快照并更新预览,返回新对象标识 // 入参: page 目标页, objects 对象快照, dx、dy 毫米位移 // 返回: any 文档信息及新选区, error 错误信息 func copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) (any, error) { @@ -900,7 +929,7 @@ func orderObjects(args []js.Value) (any, error) { }) } -// distributeObjects 按指定轴等距分布选区。 +// distributeObjects 按指定轴等距分布选区 // 入参: args 页面索引、对象标识数组和分布轴 // 返回: any 文档信息, error 错误信息 func distributeObjects(args []js.Value) (any, error) { @@ -978,7 +1007,7 @@ func createDocument(args []js.Value) (any, error) { return previewEditor(editor, args[3].Bool()) } -// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置。 +// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置 // 入参: args 浏览器参数 // 返回: any 编辑状态, error 错误信息 func editDocument(args []js.Value) (any, error) { @@ -1241,7 +1270,7 @@ func insertText(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } -// layoutText 调整文字框和段落排版,保留软换行前的原文。 +// layoutText 调整文字框和段落排版,保留软换行前的原文 // 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距和字距 // 返回: any 文档信息, error 错误信息 func layoutText(args []js.Value) (any, error) { @@ -1276,14 +1305,14 @@ func layoutText(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } -// editorBox 将库层毫米边界传递给画布交互层。 +// editorBox 将库层毫米边界传递给画布交互层 // 入参: box 毫米坐标中的矩形范围 // 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高 func editorBox(box ofdgo.Box) map[string]any { return map[string]any{"x": box.X, "y": box.Y, "width": box.W, "height": box.H} } -// editorMatrix 将标准CTM转换为浏览器仿射矩阵分量。 +// editorMatrix 将标准CTM转换为浏览器仿射矩阵分量 // 入参: matrix 库层仿射矩阵 // 返回: []any 可直接传递给JavaScript的a、b、c、d、e、f分量,位移单位为毫米 func editorMatrix(matrix ofdgo.Matrix) []any { @@ -1293,7 +1322,7 @@ func editorMatrix(matrix ofdgo.Matrix) []any { return []any{ax - x, ay - y, bx - x, by - y, x, y} } -// rotateObjects 旋转同页选区,通用几何与历史记录由库负责。 +// rotateObjects 旋转同页选区,通用几何与历史记录由库负责 // 入参: args 页面索引、对象标识数组和顺时针角度 // 返回: any 文档信息, error 错误信息 func rotateObjects(args []js.Value) (any, error) { @@ -1302,7 +1331,7 @@ func rotateObjects(args []js.Value) (any, error) { }) } -// flipObjects 镜像同页选区。 +// flipObjects 镜像同页选区 // 入参: args 页面索引、对象标识数组和镜像轴 // 返回: any 文档信息, error 错误信息 func flipObjects(args []js.Value) (any, error) { @@ -1311,7 +1340,7 @@ func flipObjects(args []js.Value) (any, error) { }) } -// cropImage 设置图片裁剪范围,保留原始图片数据。 +// cropImage 设置图片裁剪范围,保留原始图片数据 // 入参: args 页面索引、对象标识及页面毫米坐标中的横纵坐标和宽高 // 返回: any 文档信息, error 错误信息 func cropImage(args []js.Value) (any, error) { @@ -1320,7 +1349,7 @@ func cropImage(args []js.Value) (any, error) { }) } -// fitImage 按原始比例适应或填充图片框。 +// fitImage 按原始比例适应或填充图片框 // 入参: args 页面索引、对象标识和适应方式 // 返回: any 文档信息, error 错误信息 func fitImage(args []js.Value) (any, error) { @@ -1329,7 +1358,7 @@ func fitImage(args []js.Value) (any, error) { }) } -// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存。 +// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存 // 入参: args 页面索引和图片对象标识 // 返回: any 页面SVG和分离的图片资源, error 错误信息 func previewImage(args []js.Value) (any, error) { @@ -1362,7 +1391,7 @@ func previewImage(args []js.Value) (any, error) { return successResult(map[string]any{"svg": out.String(), "images": svgImagesToJS(resources.Images)}), nil } -// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64。 +// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64 // 入参: images SVG引用的图片资源 // 返回: []any 图片标识、类型和数据 func svgImagesToJS(images []ofdgo.SVGImage) []any { @@ -1373,7 +1402,7 @@ func svgImagesToJS(images []ofdgo.SVGImage) []any { return items } -// transformObjects 统一移动或缩放选区,生成一次撤销记录。 +// transformObjects 统一移动或缩放选区,生成一次撤销记录 // 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例 // 返回: any 文档信息, error 错误信息 func transformObjects(args []js.Value) (any, error) { @@ -1382,7 +1411,7 @@ func transformObjects(args []js.Value) (any, error) { }) } -// alignObjects 对齐选区中的对象。 +// alignObjects 对齐选区中的对象 // 入参: args 页面索引、对象标识数组和对齐方式 // 返回: any 文档信息, error 错误信息 func alignObjects(args []js.Value) (any, error) { @@ -1391,7 +1420,7 @@ func alignObjects(args []js.Value) (any, error) { }) } -// deleteObjects 一次删除选区中的对象。 +// deleteObjects 一次删除选区中的对象 // 入参: args 页面索引和对象标识数组 // 返回: any 文档信息, error 错误信息 func deleteObjects(args []js.Value) (any, error) { @@ -1400,7 +1429,21 @@ func deleteObjects(args []js.Value) (any, error) { }) } -// eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容。 +// eraseObjectsPath 按页面闭合折线范围擦除对象,保留局部裁剪之外的内容 +// 入参: args 页面索引、对象标识数组和坐标点数组 +// 返回: any 文档信息, error 错误信息 +func eraseObjectsPath(args []js.Value) (any, error) { + return changeObjects(func() error { + points := make([]ofdgo.Point, args[2].Length()) + for i := range points { + point := args[2].Index(i) + points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} + } + return currentEditor.EraseObjectsPath(args[0].Int(), stringsFromJS(args[1]), points) + }) +} + +// eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容 // 入参: args 页面索引、对象标识数组和毫米坐标范围 // 返回: any 文档信息, error 错误信息 func eraseObjects(args []js.Value) (any, error) { @@ -1411,7 +1454,7 @@ func eraseObjects(args []js.Value) (any, error) { }) } -// changeObjects 提交库层批量操作,无修改时复用当前预览。 +// changeObjects 提交库层批量操作,无修改时复用当前预览 // 入参: apply 待执行的编辑操作 // 返回: any 文档信息, error 错误信息 func changeObjects(apply func() error) (any, error) { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 19bc9ed..845946d 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -278,9 +278,6 @@ func (e *Editor) nextID() string { // 入参: file 字体文件, index 集合内字体索引,单字体为0 // 返回: string 字体资源标识, error 错误信息 func (e *Editor) AddFont(file FontFile, index int) (string, error) { - if index < 0 { - return "", fmt.Errorf("font index must not be negative") - } data, err := font.ToSFNT(file.Data) if err != nil { return "", err @@ -289,16 +286,11 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) { if id, ok := e.resourceID[key]; ok { return id, nil } - if bytes.HasPrefix(data, []byte("ttcf")) { - data, err = extractCollectionFont(data, index) - if err != nil { - return "", err - } - index = 0 - } else { - data = bytes.Clone(data) + data, err = (FontFile{Data: data}).Face(index) + if err != nil { + return "", err } - sfnt, err := font.ParseSFNT(data, index) + sfnt, err := font.ParseSFNT(data, 0) if err != nil { return "", err } @@ -348,7 +340,7 @@ func editorFontName(sfnt *font.SFNT, names ...font.NameID) string { } // AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档 -// 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码。 +// 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码 // 入参: data 图片数据 // 返回: string 图片资源标识, error 错误信息 func (e *Editor) AddImage(data []byte) (string, error) { @@ -389,7 +381,7 @@ func (e *Editor) AddImage(data []byte) (string, error) { return id, nil } -// editorBinaryImage 尝试纯黑白无损编码,不二值化,不改变透明度,无体积收益时保留原图。 +// editorBinaryImage 尝试纯黑白无损编码,不二值化,不改变透明度,无体积收益时保留原图 // 入参: data PNG图片数据 // 返回: []byte 更小的JBIG2数据,不适用时为nil func editorBinaryImage(data []byte) []byte { @@ -416,8 +408,8 @@ func (e *Editor) AddObject(page int, object GraphicObject) (string, error) { return ids[0], nil } -// Object 获取对象的独立副本,修改副本不影响文档。 -// 可编辑的继承样式解析为直接属性;仅在对象修改或复制时写入独立样式。 +// Object 获取对象的独立副本,修改副本不影响文档 +// 可编辑的继承样式解析为直接属性;仅在对象修改或复制时写入独立样式 // 入参: page 页面索引, id 对象标识 // 返回: GraphicObject 对象内容, error 错误信息 func (e *Editor) Object(page int, id string) (GraphicObject, error) { @@ -718,7 +710,7 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err return e.TransformObjects(page, []string{id}, dx, dy, scale) } -// transformEditorObject 变换独立对象副本,保留绘制属性和编辑中的段落信息。 +// transformEditorObject 变换独立对象副本,保留绘制属性和编辑中的段落信息 // 入参: object 对象副本, dx 横向位移, dy 纵向位移, scale 正缩放比例 // 返回: GraphicObject 变换后的对象, error 错误信息 func transformEditorObject(object GraphicObject, dx, dy, scale float64) (GraphicObject, error) { @@ -774,7 +766,7 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic return object, nil } -// ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局。 +// ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局 // 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据, fit 为contain、cover或空,空值保留原变换和裁剪 // 返回: error 错误信息 func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error { diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 005770f..4dcdb60 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -24,13 +24,13 @@ import ( "github.com/tdewolff/canvas" ) -// editorObjectPosition 对象所属图层及层内索引。 +// editorObjectPosition 对象所属图层及层内索引 type editorObjectPosition struct { layer int index int } -// compareEditorPosition 比较对象在页面结构中的先后顺序。 +// compareEditorPosition 比较对象在页面结构中的先后顺序 // 入参: a、b 对象位置 // 返回: int 比较结果 func compareEditorPosition(a, b editorObjectPosition) int { @@ -40,7 +40,7 @@ func compareEditorPosition(a, b editorObjectPosition) int { return cmp.Compare(a.index, b.index) } -// Objects 按绘制顺序获取选区的独立快照,保留编辑中的段落信息。 +// Objects 按绘制顺序获取选区的独立快照,保留编辑中的段落信息 // 入参: page 页面索引, ids 对象标识,不得重复 // 返回: []GraphicObject 独立对象副本, error 错误信息 func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) { @@ -67,8 +67,8 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) { return objects, nil } -// CopyObjects 将对象快照按输入顺序复制到目标页并平移,分配新ID,提交一次撤销记录。 -// 对象引用当前Editor已注册的资源,保留段落信息,不修改输入快照。 +// CopyObjects 将对象快照按输入顺序复制到目标页并平移,分配新ID,提交一次撤销记录 +// 对象引用当前Editor已注册的资源,保留段落信息,不修改输入快照 // 入参: page 目标页面索引, objects 对象快照, dx、dy 毫米位移 // 返回: []string 按绘制顺序排列的新对象标识, error 错误信息 func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) ([]string, error) { @@ -132,7 +132,7 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) return result, nil } -// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象。 +// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象 type ObjectPosition struct { Layer string Container string @@ -140,7 +140,7 @@ type ObjectPosition struct { Count int } -// ObjectPosition 获取对象的直接容器及排序位置。 +// ObjectPosition 获取对象的直接容器及排序位置 // 入参: page 页面索引, id 对象标识 // 返回: ObjectPosition 对象位置, error 错误信息 func (e *Editor) ObjectPosition(page int, id string) (ObjectPosition, error) { @@ -162,7 +162,7 @@ func (e *Editor) ObjectPosition(page int, id string) (ObjectPosition, error) { return position, nil } -// objectOrderIndexes 获取同一直接容器的对象索引,不跨越图层或页块。 +// objectOrderIndexes 获取同一直接容器的对象索引,不跨越图层或页块 // 入参: page 页面索引, layer 图层, id 对象标识 // 返回: *editorXML 原始容器, []int 对象索引 func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorXML, []int) { @@ -184,7 +184,7 @@ func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorX return parent, indexes } -// OrderObjects 调整同一图层或页块内选区的绘制顺序,保留各组选中及未选中对象的相对顺序。 +// OrderObjects 调整同一图层或页块内选区的绘制顺序,保留各组选中及未选中对象的相对顺序 // 入参: page 页面索引, ids 对象标识, order 为up、down、top或bottom // 返回: error 错误信息 func (e *Editor) OrderObjects(page int, ids []string, order string) error { @@ -248,8 +248,8 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error { return nil } -// DistributeObjects 按可见范围等距分布同页对象,固定两端对象,保留绘制顺序。 -// 不足三个对象时不修改;允许负间距,无法保持位置顺序的重叠布局返回错误。 +// DistributeObjects 按可见范围等距分布同页对象,固定两端对象,保留绘制顺序 +// 不足三个对象时不修改;允许负间距,无法保持位置顺序的重叠布局返回错误 // 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical // 返回: error 错误信息 func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { @@ -317,7 +317,7 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { return e.updateObjects(page, updates, true) } -// replaceLayers 替换图层容器,隔离历史快照与后续的增删、排序操作。 +// replaceLayers 替换图层容器,隔离历史快照与后续的增删、排序操作 // 入参: page 页面索引, layers 新图层列表 func (e *Editor) replaceLayers(page int, layers []Layer) { before := copyEditorPage(e.pages[page]).Content.Layer @@ -335,14 +335,14 @@ func (e *Editor) replaceLayers(page int, layers []Layer) { } } -// UpdateObjects 原子替换同页对象,按各对象ID定位,全部校验通过后提交一次历史记录。 +// UpdateObjects 原子替换同页对象,按各对象ID定位,全部校验通过后提交一次历史记录 // 入参: page 页面索引, objects 新对象内容,ID不得重复 // 返回: error 错误信息 func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error { return e.updateObjects(page, objects, false) } -// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区。 +// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区 // 入参: page 页面索引, objects 新对象, geometry 是否保留原内容的几何操作 // 返回: error 错误信息 func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) error { @@ -399,7 +399,7 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) return nil } -// TransformObjects 同页对象以页面原点等比缩放后统一平移,保持相对位置,一次撤销恢复全部。 +// TransformObjects 同页对象以页面原点等比缩放后统一平移,保持相对位置,一次撤销恢复全部 // 入参: page 页面索引, ids 对象标识, dx、dy 位移, scale 正缩放比例 // 返回: error 错误信息 func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64) error { @@ -434,7 +434,7 @@ func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64) return e.updateObjects(page, objects, true) } -// AlignObjects 单对象对齐页面,多对象相互对齐至选区边界,文字采用实际字形范围。 +// AlignObjects 单对象对齐页面,多对象相互对齐至选区边界,文字采用实际字形范围 // 入参: page 页面索引, ids 对象标识, alignment 为left、center、right、top、middle或bottom // 返回: error 错误信息 func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { @@ -484,7 +484,7 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { return e.updateObjects(page, objects, true) } -// DeleteObjects 原子删除同页对象,保留其余对象顺序,一次撤销恢复全部。 +// DeleteObjects 原子删除同页对象,保留其余对象顺序,一次撤销恢复全部 // 入参: page 页面索引, ids 对象标识 // 返回: error 错误信息 func (e *Editor) DeleteObjects(page int, ids []string) error { @@ -511,7 +511,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error { return nil } -// selectedObjects 校验同页选择,返回内部只读对象与对应索引。 +// selectedObjects 校验同页选择,返回内部只读对象与对应索引 // 入参: page 页面索引, ids 对象标识,不得重复 // 返回: []GraphicObject 所选对象, []editorObjectPosition 图层和对象索引, error 错误信息 func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []editorObjectPosition, error) { @@ -541,7 +541,7 @@ func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []edi return objects, indexes, nil } -// editorObjectID 获取可创作对象的标准标识。 +// editorObjectID 获取可创作对象的标准标识 // 入参: object 图形对象 // 返回: string 对象标识,不支持的类型返回空字符串 func editorObjectID(object GraphicObject) string { @@ -558,7 +558,7 @@ func editorObjectID(object GraphicObject) string { return "" } -// objectBounds 批量获取对齐范围,复用同一渲染器的资源和字形缓存。 +// objectBounds 批量获取对齐范围,复用同一渲染器的资源和字形缓存 // 入参: page 页面索引, objects 待度量的对象 // 返回: []Box 对象范围, error 错误信息 func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) { diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go index a5dfa3d..f12b1de 100644 --- a/ofdgo_editor_erase.go +++ b/ofdgo_editor_erase.go @@ -19,17 +19,55 @@ import ( "math" "reflect" "slices" + "strings" + + "github.com/tdewolff/canvas" ) -// EraseObjects 擦除同页对象与矩形区域重叠的可见部分,一次撤销恢复全部。 -// 全部覆盖时删除对象;局部覆盖使用OFD路径裁剪,保留原文字和资源,不用于敏感信息脱敏。 -// 不改变未相交对象,无法保真编辑时返回错误且不提交修改。 +// EraseObjects 擦除同页对象与矩形区域重叠的可见部分,一次撤销恢复全部 +// 全部覆盖时删除对象;局部覆盖使用OFD路径裁剪,保留原文字和资源,不用于敏感信息脱敏 +// 不改变未相交对象,无法保真编辑时返回错误且不提交修改 // 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的擦除范围 // 返回: error 错误信息 func (e *Editor) EraseObjects(page int, ids []string, box Box) error { if _, err := creationBox(editorBoxString(box)); err != nil { return err } + return e.eraseObjects(page, ids, box, nil, nil) +} + +// EraseObjectsPath 擦除同页对象与闭合折线区域重叠的可见部分,末点自动连接起点 +// 自交区域使用奇偶规则;复用矩形擦除的裁剪、原子修改和撤销逻辑,不用于敏感信息脱敏 +// 入参: page 页面索引, ids 对象标识, points 页面毫米坐标,至少三个点 +// 返回: error 错误信息 +func (e *Editor) EraseObjectsPath(page int, ids []string, points []Point) error { + if len(points) < 3 { + return fmt.Errorf("erase path requires at least three points") + } + region := &canvas.Path{} + for i, point := range points { + if !finite(point.X) || !finite(point.Y) { + return fmt.Errorf("invalid erase path point %d", i) + } + if i == 0 { + region.MoveTo(point.X, point.Y) + } else { + region.LineTo(point.X, point.Y) + } + } + region.Close() + region = region.Settle(canvas.EvenOdd) + if region.Empty() { + return nil + } + bounds := region.FastBounds() + return e.eraseObjects(page, ids, Box{X: bounds.X0, Y: bounds.Y0, W: bounds.W(), H: bounds.H()}, points, region) +} + +// eraseObjects 按矩形或闭合路径裁剪对象,只在全部对象处理成功后提交修改 +// 入参: page 页面索引, ids 对象标识, box 区域边界, points 折线点, region 奇偶填充后的路径 +// 返回: error 错误信息 +func (e *Editor) eraseObjects(page int, ids []string, box Box, points []Point, region *canvas.Path) error { objects, indexes, err := e.selectedObjects(page, ids) if err != nil || len(objects) == 0 { return err @@ -48,7 +86,15 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { if right <= x || bottom <= y { continue } - if x == bounds.X && y == bounds.Y && right == bounds.X+bounds.W && bottom == bounds.Y+bounds.H { + covered := x == bounds.X && y == bounds.Y && right == bounds.X+bounds.W && bottom == bounds.Y+bounds.H + if region != nil { + path := canvas.Rectangle(bounds.W, bounds.H).Translate(bounds.X, bounds.Y) + if path.And(region).Empty() { + continue + } + covered = path.Not(region).Empty() + } + if covered { removed[ids[i]] = true changed = true continue @@ -79,12 +125,16 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { } } clip := Clip{} - for _, area := range []Box{ + areas := []Box{ {X: outer.X, Y: outer.Y, W: outer.W, H: y - outer.Y}, {X: outer.X, Y: bottom, W: outer.W, H: outer.Y + outer.H - bottom}, {X: outer.X, Y: y, W: x - outer.X, H: bottom - y}, {X: right, Y: y, W: outer.X + outer.W - right, H: bottom - y}, - } { + } + if region != nil { + areas = []Box{unionTextBox(outer, box)} + } + for _, area := range areas { if area.W <= 0 || area.H <= 0 { continue } @@ -95,6 +145,19 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { } fill, stroke := true, false path.Fill, path.Stroke = &fill, &stroke + if region != nil { + var data strings.Builder + data.WriteString(path.AbbreviatedData) + for i, point := range points { + command := "L" + if i == 0 { + command = "M" + } + fmt.Fprintf(&data, " %s %g %g", command, point.X-frame.X-area.X, point.Y-frame.Y-area.Y) + } + data.WriteString(" C") + path.AbbreviatedData, path.Rule = data.String(), "Even-Odd" + } clip.Area = append(clip.Area, ClipArea{CTM: matrix.String(), Path: []PathObject{path}}) } if slices.ContainsFunc((*clips).Clip, func(previous Clip) bool { return reflect.DeepEqual(previous, clip) }) { @@ -120,7 +183,7 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { return nil } -// editorObjectClips 获取基本对象的裁剪字段。 +// editorObjectClips 获取基本对象的裁剪字段 // 入参: object 文字、路径或图片对象 // 返回: **Clips 裁剪字段地址,不支持的对象返回nil func editorObjectClips(object *GraphicObject) **Clips { diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 953b4e8..0dfed57 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -16,13 +16,167 @@ package ofdgo import ( "bytes" + "encoding/binary" "fmt" + "maps" "slices" + "strconv" + "strings" "github.com/tdewolff/font" ) -// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,不修改编辑资源或原文档字体。 +// editorFontUsage 汇总原文档字体的字符、显式字形及无法确定的引用 +type editorFontUsage struct { + chars map[rune]bool + glyphs map[uint16]bool + unsafe bool +} + +// newEditorFontUsage 创建字体用字记录 +// 返回: *editorFontUsage 用字记录 +func newEditorFontUsage() *editorFontUsage { + return &editorFontUsage{chars: make(map[rune]bool), glyphs: make(map[uint16]bool)} +} + +// fontUsage 获取规范化资源标识对应的字体用字记录 +// 入参: id 字体资源标识 +// 返回: *editorFontUsage 用字记录 +func (r *editorResourceRefs) fontUsage(id string) *editorFontUsage { + if r.fonts == nil { + r.fonts = make(map[string]*editorFontUsage) + } + id = editorResourceID(id) + if r.fonts[id] == nil { + r.fonts[id] = newEditorFontUsage() + } + return r.fonts[id] +} + +// text 收集TextCode字符和CGTransform显式字形,异常编号使该字体保持原样 +// 入参: key 元素名称, value 元素文本 +func (u *editorFontUsage) text(key, value string) { + switch key { + case "TextCode": + for _, char := range textCodeRunes(value) { + u.chars[char] = true + } + case "Glyphs": + for _, value := range strings.Fields(strings.ReplaceAll(value, ",", " ")) { + id, err := strconv.ParseUint(value, 10, 16) + if err != nil { + u.unsafe = true + return + } + u.glyphs[uint16(id)] = true + } + } +} + +// merge 合并指向同一字体文件的全部资源用字 +// 入参: other 用字记录, unsafe 是否存在无法确定的引用 +func (u *editorFontUsage) merge(other *editorFontUsage, unsafe bool) { + u.unsafe = u.unsafe || unsafe + if other != nil { + u.unsafe = u.unsafe || other.unsafe + maps.Copy(u.chars, other.chars) + maps.Copy(u.glyphs, other.glyphs) + } +} + +// subsetSourceFont 裁剪静态TrueType原有字体,保留字形编号、复合依赖、度量及提示指令 +// 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复 +// 入参: data 字体数据, usage 全包用字记录 +// 返回: []byte 更小的字体子集,无确定收益时为空 +func subsetSourceFont(data []byte, usage *editorFontUsage) []byte { + if usage.unsafe || len(usage.chars)+len(usage.glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) { + return nil + } + sfnt, err := font.ParseSFNT(data, 0) + if err != nil || !sfnt.IsTrueType { + return nil + } + for tag := range sfnt.Tables { + switch tag { + case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM": + default: + return nil + } + } + var unicodeTables []uint16 + subtables := make(map[uint32]uint16) + for i, record := range sfnt.Cmap.EncodingRecords { + if record.Format == 14 { + return nil + } + offset := binary.BigEndian.Uint32(sfnt.Tables["cmap"][8+8*i:]) + index, ok := subtables[offset] + if !ok { + index = uint16(len(subtables)) + subtables[offset] = index + } + if record.PlatformID == 0 || record.PlatformID == 3 && (record.EncodingID == 1 || record.EncodingID == 10) { + unicodeTables = append(unicodeTables, index) + } + } + if len(unicodeTables) == 0 { + return nil + } + glyphs := maps.Clone(usage.glyphs) + glyphs[0] = true + mapping := make(map[rune]uint16, len(usage.chars)) + for char := range usage.chars { + id := sfnt.GlyphIndex(char) + for _, index := range unicodeTables { + if alternate, ok := sfnt.Cmap.Subtables[index].Get(char); ok && alternate != id { + return nil + } + } + glyphs[id] = true + if id != 0 { + mapping[char] = id + } + } + for id := range glyphs { + if id >= sfnt.NumGlyphs() { + return nil + } + dependencies, err := sfnt.Glyf.Dependencies(id) + if err != nil { + return nil + } + for _, dependency := range dependencies { + glyphs[dependency] = true + } + } + var glyf, loca []byte + for id := 0; id <= int(sfnt.NumGlyphs()); id++ { + if sfnt.Head.IndexToLocFormat == 0 { + loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2)) + } else { + loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf))) + } + if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] { + glyf = append(glyf, sfnt.Glyf.Get(uint16(id))...) + if len(glyf)%2 != 0 { + glyf = append(glyf, 0) + } + } + } + tables := maps.Clone(sfnt.Tables) + tables["glyf"], tables["loca"] = glyf, loca + tables["cmap"] = buildCmapTable(uint16(sfnt.NumGlyphs()), mapping) + tables["head"] = bytes.Clone(tables["head"]) + clear(tables["head"][8:12]) + delete(tables, "DSIG") + result, err := serializeOTF(tables) + if err != nil || len(result) >= len(data) { + return nil + } + return result +} + +// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,不修改编辑资源或原文档字体 // 返回: map[string][]byte 包内字体子集, error 错误信息 func (e *Editor) subsetFonts() (map[string][]byte, error) { used := make(map[string]map[uint16]bool) @@ -77,7 +231,7 @@ func (e *Editor) subsetFonts() (map[string][]byte, error) { return result, nil } -// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令。 +// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令 // 入参: data 字体数据, glyphs 已排序的字形编号,包含0 // 返回: []byte 字体子集, error 错误信息 func subsetEditorFont(data []byte, glyphs []uint16) ([]byte, error) { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index dcd3bd9..797962d 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -28,7 +28,7 @@ import ( "github.com/tdewolff/font" ) -// editorSource 保留输入包及按需加载的原始页面。 +// editorSource 保留输入包及按需加载的原始页面 type editorSource struct { reader *Reader document *Document @@ -39,7 +39,7 @@ type editorSource struct { idsReady bool } -// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用。 +// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用 type editorObjectOrigin struct { page *editorSourcePage node *editorXML @@ -47,7 +47,7 @@ type editorObjectOrigin struct { reason string } -// editorSourcePage 保存页面原文、解析快照及图层和页块内的对象位置。 +// editorSourcePage 保存页面原文、解析快照及图层和页块内的对象位置 type editorSourcePage struct { ref Page data []byte @@ -56,9 +56,9 @@ type editorSourcePage struct { nodes map[string]*editorXML } -// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因。 -// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块。 -// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复。 +// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因 +// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块 +// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复 type ObjectCapabilities struct { Update bool ReplaceFont bool @@ -72,7 +72,7 @@ type ObjectCapabilities struct { Reason string } -// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用。 +// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用 type PageCapabilities struct { Insert bool Copy bool @@ -81,9 +81,9 @@ type PageCapabilities struct { Resize bool } -// Editor 打开主文档进行保真编辑,不修改输入包,不以权限声明或签名限制编辑。 -// 原权限和签名数据保留,修改受保护内容会使原签名失效,不重新签名。 -// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader。 +// Editor 打开主文档进行保真编辑,不修改输入包,不以权限声明或签名限制编辑 +// 原权限和签名数据保留,修改受保护内容会使原签名失效,不重新签名 +// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader // 返回: *Editor 编辑器, error 错误信息 func (r *Reader) Editor() (*Editor, error) { if r.Zip != nil { @@ -129,7 +129,7 @@ func (r *Reader) Editor() (*Editor, error) { return e, nil } -// editorDirectoryExists 判断包内目录是否已经使用,避免覆盖原始资源。 +// editorDirectoryExists 判断包内目录是否已经使用,避免覆盖原始资源 // 入参: reader 输入包, directory 目录路径 // 返回: bool 是否存在 func editorDirectoryExists(reader *Reader, directory string) bool { @@ -146,7 +146,7 @@ func editorDirectoryExists(reader *Reader, directory string) bool { return false } -// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID。 +// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID // 返回: error 错误信息 func (e *Editor) prepareSourceIDs() error { if e.source == nil || e.source.idsReady { @@ -180,7 +180,7 @@ func (e *Editor) prepareSourceIDs() error { return nil } -// editorXMLMaxID 流式读取OFD结构中的最大标识,跳过非OFD的XML附件。 +// editorXMLMaxID 流式读取OFD结构中的最大标识,跳过非OFD的XML附件 // 入参: input XML输入流 // 返回: int 最大标识, error 错误信息 func editorXMLMaxID(input io.Reader) (int, error) { @@ -217,7 +217,7 @@ func editorXMLMaxID(input io.Reader) (int, error) { } } -// resourceDirectory 获取新增二进制资源的目录。 +// resourceDirectory 获取新增二进制资源的目录 // 返回: string 包内路径 func (e *Editor) resourceDirectory() string { if e.source != nil { @@ -226,14 +226,14 @@ func (e *Editor) resourceDirectory() string { return "Doc_0/Res" } -// originalPage 判断索引是否指向原文档页面。 +// originalPage 判断索引是否指向原文档页面 // 入参: index 页面索引 // 返回: bool 是否为原始页面 func (e *Editor) originalPage(index int) bool { return e.source != nil && index >= 0 && index < len(e.pages) && e.source.pages[e.pages[index].ID] != nil } -// PageCapabilities 获取页面操作范围;原始页面暂不复制、删除,避免破坏模板与外部引用。 +// PageCapabilities 获取页面操作范围;原始页面暂不复制、删除,避免破坏模板与外部引用 // 入参: index 页面索引 // 返回: PageCapabilities 操作能力, error 错误信息 func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) { @@ -244,7 +244,7 @@ func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) { return PageCapabilities{Insert: e.sourceRGB(), Copy: !original, Delete: !original, Move: true, Resize: true}, nil } -// sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间。 +// sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间 // 返回: bool 是否为8位RGB文档 func (e *Editor) sourceRGB() bool { if e.source == nil || e.source.document.CommonData.DefaultCS == 0 { @@ -254,7 +254,7 @@ func (e *Editor) sourceRGB() bool { return space != nil && space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0 } -// loadSourcePage 首次访问时解析原始页面,不加载图片或字体二进制数据。 +// loadSourcePage 首次访问时解析原始页面,不加载图片或字体二进制数据 // 入参: index 页面索引 // 返回: error 错误信息 func (e *Editor) loadSourcePage(index int) error { @@ -310,9 +310,9 @@ func (e *Editor) loadSourcePage(index int) error { return nil } -// ObjectCapabilities 获取对象可执行的操作,不支持的内容保持原文,不强制转换。 -// 字体缺失或子集不包含原文时仍可移动、删除,不允许重新排版或复制。 -// 显式提供可用字体后可通过UpdateObject替换;原文布局未知时不自动推断段落选项。 +// ObjectCapabilities 获取对象可执行的操作,不支持的内容保持原文,不强制转换 +// 字体缺失或子集不包含原文时仍可移动、删除,不允许重新排版或复制 +// 显式提供可用字体后可通过UpdateObject替换;原文布局未知时不自动推断段落选项 // 入参: page 页面索引, id 对象标识 // 返回: ObjectCapabilities 操作能力, error 错误信息 func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, error) { @@ -365,7 +365,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er return all, nil } -// objectOrigin 获取原对象或其副本的保真来源。 +// objectOrigin 获取原对象或其副本的保真来源 // 入参: id 对象标识 // 返回: *editorObjectOrigin 原始对象,无来源时为nil func (e *Editor) objectOrigin(id string) *editorObjectOrigin { @@ -375,7 +375,7 @@ func (e *Editor) objectOrigin(id string) *editorObjectOrigin { return e.source.origins[id] } -// editorContainerOrderable 判断容器的直接对象能否在保留页块的前提下排序。 +// editorContainerOrderable 判断容器的直接对象能否在保留页块的前提下排序 // 入参: container 图层或页块 // 返回: bool 是否可排序 func editorContainerOrderable(container *editorXML) bool { @@ -387,7 +387,7 @@ func editorContainerOrderable(container *editorXML) bool { return true } -// editorGeometry 获取基本对象的边界与变换。 +// editorGeometry 获取基本对象的边界与变换 // 入参: object 对象 // 返回: string 边界, string 变换矩阵 func editorGeometry(object GraphicObject) (string, string) { @@ -402,7 +402,7 @@ func editorGeometry(object GraphicObject) (string, string) { return "", "" } -// editorFont 按需解析原文档内嵌字体,不用系统回退替换原字体。 +// editorFont 按需解析原文档内嵌字体,不用系统回退替换原字体 // 入参: id 字体资源标识 // 返回: *font.SFNT 字体, error 错误信息 func (e *Editor) editorFont(id string) (*font.SFNT, error) { @@ -431,7 +431,7 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) { return nil, fmt.Errorf("embedded font %q not found", id) } -// editorImage 按需读取原图片尺寸,不解码完整像素。 +// editorImage 按需读取原图片尺寸,不解码完整像素 // 入参: id 图片资源标识 // 返回: image.Point 图片尺寸, error 错误信息 func (e *Editor) editorImage(id string) (image.Point, error) { @@ -456,14 +456,14 @@ func (e *Editor) editorImage(id string) (image.Point, error) { return size, nil } -// cloneEditorData 深复制编辑快照,未导出的排版信息保持不可变共享。 +// cloneEditorData 深复制编辑快照,未导出的排版信息保持不可变共享 // 入参: value 原值 // 返回: T 独立副本 func cloneEditorData[T any](value T) T { return cloneEditorValue(reflect.ValueOf(value)).Interface().(T) } -// cloneEditorValue 复制结构体中的可变指针和切片。 +// cloneEditorValue 复制结构体中的可变指针和切片 // 入参: value 原值 // 返回: reflect.Value 独立副本 func cloneEditorValue(value reflect.Value) reflect.Value { @@ -495,7 +495,7 @@ func cloneEditorValue(value reflect.Value) reflect.Value { return value } -// editorObjectXML 编码可编辑对象,包含独立命名空间声明。 +// editorObjectXML 编码可编辑对象,包含独立命名空间声明 // 入参: object 对象 // 返回: []byte XML片段, error 错误信息 func editorObjectXML(object GraphicObject) ([]byte, error) { @@ -503,7 +503,7 @@ func editorObjectXML(object GraphicObject) ([]byte, error) { return bytes.TrimPrefix(data, []byte(xml.Header)), err } -// validateEditorGeometry 校验几何操作产生的数值,不重新排版或替换字体。 +// validateEditorGeometry 校验几何操作产生的数值,不重新排版或替换字体 // 入参: object 变换后的对象 // 返回: error 错误信息 func validateEditorGeometry(object GraphicObject) error { diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index d64fe7d..6be8c37 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -28,7 +28,7 @@ import ( "strings" ) -// sourceParts 生成编辑过的XML和新增资源,不读取未修改页面或原始二进制资源。 +// sourceParts 生成编辑过的XML和新增资源,不读取未修改页面或原始二进制资源 // 返回: map[string][]byte 替换及新增条目, error 错误信息 func (e *Editor) sourceParts() (map[string][]byte, error) { parts := make(map[string][]byte) @@ -171,7 +171,7 @@ func (e *Editor) sourceParts() (map[string][]byte, error) { return parts, nil } -// sourcePageXML 仅改写已修改的对象、页面尺寸和新增图层,保留原页块层级。 +// sourcePageXML 仅改写已修改的对象、页面尺寸和新增图层,保留原页块层级 // 入参: index 页面索引, source 原页面 // 返回: []byte 页面XML, error 错误信息 func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, error) { @@ -290,7 +290,7 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err return editorPatchXML(data, patches), nil } -// sourceLayerXML 写出新增图层,对复制对象保留原始定位和显式默认值。 +// sourceLayerXML 写出新增图层,对复制对象保留原始定位和显式默认值 // 入参: layer 图层 // 返回: []byte 图层XML, error 错误信息 func (e *Editor) sourceLayerXML(layer Layer) ([]byte, error) { @@ -314,7 +314,7 @@ func (e *Editor) sourceLayerXML(layer Layer) ([]byte, error) { return editorXMLContainer("Layer", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: layer.ID}, {Name: xml.Name{Local: "Type"}, Value: layer.Type}}, content) } -// sourceNewPageXML 编码新增页面,同时保留复制对象的原文语义。 +// sourceNewPageXML 编码新增页面,同时保留复制对象的原文语义 // 入参: page 页面 // 返回: []byte 页面XML, error 错误信息 func (e *Editor) sourceNewPageXML(page PageContent) ([]byte, error) { @@ -337,7 +337,7 @@ func (e *Editor) sourceNewPageXML(page PageContent) ([]byte, error) { return editorXMLContainer("Page", nil, append(area, content...)) } -// sourceInfoXML 更新公开元数据,保留原创建程序和未识别字段。 +// sourceInfoXML 更新公开元数据,保留原创建程序和未识别字段 // 返回: []byte 根索引XML, error 错误信息 func (e *Editor) sourceInfoXML() ([]byte, error) { data, err := e.source.reader.readFile("OFD.xml") @@ -373,7 +373,7 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { return editorXMLSetText(data, info, values), nil } -// writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本。 +// writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本 // 入参: writer 输出流, fonts 新增字体子集 // 返回: int64 写入字节数, error 错误信息 func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, error) { @@ -382,7 +382,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, return 0, err } maps.Copy(parts, fonts) - removed, err := e.pruneSourceResources(parts) + removed, err := e.compactSourceResources(parts) if err != nil { return 0, err } @@ -445,7 +445,7 @@ func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, return output.count, err } -// sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩。 +// sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩 // 返回: *Reader 阅读器, error 错误信息 func (e *Editor) sourceReader() (*Reader, error) { parts, err := e.sourceParts() diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index 5bafbe2..15fa91e 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -26,18 +26,19 @@ import ( "strings" ) -// editorResourceRefs 保存全包资源标识和直接文件引用,不以页面是否加载判断资源存活。 +// editorResourceRefs 保存全包资源标识和直接文件引用,不以页面是否加载判断资源存活 type editorResourceRefs struct { ids map[string]bool files map[string]bool + fonts map[string]*editorFontUsage } -// pruneSourceResources 在保存副本中清理无引用字体和图片,保留其他资源及原始编辑状态。 -// 按GB/T 33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档。 -// 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过。 +// compactSourceResources 在保存副本中清理无引用字体和图片,并裁剪可完整确认用字的原有字体 +// 按GB/T 33190-2016附录A检查标识和路径引用,包括模板、注释、底纹、裁剪、版本和其他文档 +// 全包检查保留孤立XML中的引用;未知命名空间、扩展数据或无法解析的XML使本次清理跳过 // 入参: parts 已修改和新增的包内条目 // 返回: map[string]bool 可移除的二进制条目, error 读取错误 -func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, error) { +func (e *Editor) compactSourceResources(parts map[string][]byte) (map[string]bool, error) { if len(parts) == 0 { return nil, nil } @@ -52,7 +53,7 @@ func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, for name := range parts { names[name] = true } - refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} + refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)} var resources []string for _, name := range slices.Sorted(maps.Keys(names)) { if strings.HasSuffix(name, "/") { @@ -80,6 +81,7 @@ func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, removed := make(map[string]bool) usedFiles := maps.Clone(refs.files) updates := make(map[string][]byte) + fontFiles := make(map[string]*editorFontUsage) for _, name := range resources { data, ok := parts[name] if !ok { @@ -114,9 +116,21 @@ func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, return nil, nil } if location := editorResourceLocation(name, root.attr("BaseLoc"), value); location != "" { + alternate := strings.ToLower(cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value))) + if field == "FontFile" { + usage := refs.fonts[id] + charset := node.attr("Charset") + unsafe := id == "" || charset != "" && !strings.EqualFold(charset, "unicode") || location != alternate + for _, file := range []string{location, alternate} { + if fontFiles[file] == nil { + fontFiles[file] = newEditorFontUsage() + } + fontFiles[file].merge(usage, unsafe || keep && usage == nil) + } + } if keep { usedFiles[location] = true - usedFiles[strings.ToLower(cleanPackagePath(resolveResourcePath(name, root.attr("BaseLoc"), value)))] = true + usedFiles[alternate] = true } else { removed[location] = true } @@ -139,6 +153,20 @@ func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, for name := range usedFiles { delete(removed, name) } + for name := range names { + key := strings.ToLower(cleanPackagePath(name)) + usage := fontFiles[key] + if usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil { + continue + } + data, err := reader.readFile(name) + if err != nil { + return nil, err + } + if subset := subsetSourceFont(data, usage); subset != nil { + updates[name] = subset + } + } maps.Copy(parts, updates) for name := range parts { if removed[strings.ToLower(cleanPackagePath(name))] { @@ -148,7 +176,7 @@ func (e *Editor) pruneSourceResources(parts map[string][]byte) (map[string]bool, return removed, nil } -// scan 流式读取包内XML引用,二进制条目只探测文件头,不展开页面对象或图片像素。 +// scan 流式读取包内XML引用,二进制条目只探测文件头,不展开页面对象或图片像素 // 入参: input 文件流, name 包内路径 // 返回: bool 是否资源索引, bool 是否可完整判断引用 func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { @@ -166,6 +194,7 @@ func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { } decoder := xml.NewDecoder(buffer) var stack []string + var fonts []*editorFontUsage var text strings.Builder resource, base := false, "" for { @@ -194,6 +223,13 @@ func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { } } } + var usage *editorFontUsage + if len(fonts) != 0 { + usage = fonts[len(fonts)-1] + if usage != nil && (stack[len(stack)-1] == "TextCode" || stack[len(stack)-1] == "Glyphs") { + usage.unsafe = true + } + } for _, attr := range token.Attr { if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Space == "http://www.w3.org/XML/1998/namespace" { continue @@ -204,14 +240,30 @@ func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { if attr.Name.Local != "ID" { r.reference(name, base, attr.Name.Local, attr.Value, resource) } + if attr.Name.Local == "Font" { + usage = r.fontUsage(attr.Value) + if token.Name.Local != "TextObject" && token.Name.Local != "Text" { + usage.unsafe = true + } + } else if attr.Name.Local == "Substitution" { + r.fontUsage(attr.Value).unsafe = true + } } stack = append(stack, token.Name.Local) + fonts = append(fonts, usage) text.Reset() case xml.CharData: text.Write(token) case xml.EndElement: r.reference(name, base, token.Name.Local, text.String(), resource) + if (token.Name.Local == "Substitution" || token.Name.Local == "Font") && strings.TrimSpace(text.String()) != "" { + r.fontUsage(text.String()).unsafe = true + } + if usage := fonts[len(fonts)-1]; usage != nil { + usage.text(token.Name.Local, text.String()) + } stack = stack[:len(stack)-1] + fonts = fonts[:len(fonts)-1] text.Reset() case xml.Directive: return false, false @@ -219,7 +271,7 @@ func (r *editorResourceRefs) scan(input io.Reader, name string) (bool, bool) { } } -// reference 记录标准资源标识和路径引用,不将普通数字或资源自身ID视为引用。 +// reference 记录标准资源标识和路径引用,不将普通数字或资源自身ID视为引用 // 入参: name 当前文件, base 资源目录, key 字段, value 值, resource 是否资源索引 func (r *editorResourceRefs) reference(name, base, key, value string, resource bool) { value = strings.TrimSpace(value) @@ -248,7 +300,7 @@ func (r *editorResourceRefs) reference(name, base, key, value string, resource b r.files[strings.ToLower(cleanPackagePath(resolveResourcePath(name, base, value)))] = true } -// editorResourceID 规范化ST_ID与ST_RefID的十进制表示。 +// editorResourceID 规范化ST_ID与ST_RefID的十进制表示 // 入参: value 标识文本 // 返回: string 标准十进制标识,无效时为空 func editorResourceID(value string) string { @@ -259,7 +311,7 @@ func editorResourceID(value string) string { return strconv.FormatUint(id, 10) } -// editorResourceLocation 按ST_Loc与Res.BaseLoc解析路径,大小写折叠用于保守保留共享文件。 +// editorResourceLocation 按ST_Loc与Res.BaseLoc解析路径,大小写折叠用于保守保留共享文件 // 入参: name 所在XML, base 资源目录, value 路径值 // 返回: string 包内路径 func editorResourceLocation(name, base, value string) string { diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index e1fdc12..15c18c5 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -16,7 +16,7 @@ package ofdgo import "fmt" -// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照。 +// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照 // 入参: id 绘制参数标识, visited 已访问标识 // 返回: *DrawParam 合并参数, error 错误信息 func (e *Editor) editorDrawParam(id string, visited map[string]bool) (*DrawParam, error) { @@ -35,7 +35,7 @@ func (e *Editor) editorDrawParam(id string, visited map[string]bool) (*DrawParam return mergeDrawParam(*base, dp), nil } -// resolveEditorStyle 为对象生成独立的有效样式,不改写共享资源或原XML。 +// resolveEditorStyle 为对象生成独立的有效样式,不改写共享资源或原XML // 入参: object 原对象, layer 图层绘制参数标识 // 返回: GraphicObject 编辑快照, error 错误信息 func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (GraphicObject, error) { diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 3a1b37b..7aa4499 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -20,9 +20,9 @@ import ( "github.com/go-text/typesetting/segmenter" ) -// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify。 -// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负。 -// 零值保持显式换行和左对齐。 +// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify +// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 +// 零值保持显式换行和左对齐 type TextLayout struct { Wrap bool Align string @@ -30,14 +30,14 @@ type TextLayout struct { LetterSpacing float64 } -// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染。 +// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染 type textLayout struct { value string options TextLayout } -// TextLayout 获取最近一次排版的原文与选项,区分软换行和显式换行。 -// 信息仅在当前编辑过程保留,读取OFD时返回定位后的文字和默认选项。 +// TextLayout 获取最近一次排版的原文与选项,区分软换行和显式换行 +// 信息仅在当前编辑过程保留,读取OFD时返回定位后的文字和默认选项 // 返回: string 原文, TextLayout 排版选项 func (obj TextObject) TextLayout() (string, TextLayout) { if obj.layout != nil { @@ -46,7 +46,7 @@ func (obj TextObject) TextLayout() (string, TextLayout) { return obj.Text(), TextLayout{} } -// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。 +// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白 // 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距 // 返回: [][2]int 各行的字符起止索引,左闭右开 func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing float64) [][2]int { @@ -106,7 +106,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, return lines } -// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。 +// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐 // 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距 // 返回: float64 行首X坐标, string 字符间的DeltaX序列 func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) { @@ -150,7 +150,7 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st return x, strings.Join(deltas, " ") } -// spaceTextAdvances 将字距加入各字素末尾,不拆散组合字符。 +// spaceTextAdvances 将字距加入各字素末尾,不拆散组合字符 // 入参: runes 段落字符, advances 待更新的字符步进, spacing 附加毫米字距 func spaceTextAdvances(runes []rune, advances []float64, spacing float64) { if spacing == 0 { diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index b29fbaa..5b16551 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -20,15 +20,15 @@ import ( "slices" ) -// RotateObject 绕对象可见范围中心旋转。 +// RotateObject 绕对象可见范围中心旋转 // 入参: page 页面索引, id 对象标识, degrees 顺时针角度 // 返回: error 错误信息 func (e *Editor) RotateObject(page int, id string, degrees int) error { return e.RotateObjects(page, []string{id}, degrees) } -// RotateObjects 绕同页选区的可见范围中心旋转。 -// 保留相对位置及资源,一次撤销恢复全部。 +// RotateObjects 绕同页选区的可见范围中心旋转 +// 保留相对位置及资源,一次撤销恢复全部 // 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍 // 返回: error 错误信息 func (e *Editor) RotateObjects(page int, ids []string, degrees int) error { @@ -40,15 +40,15 @@ func (e *Editor) RotateObjects(page int, ids []string, degrees int) error { return e.orientObjects(page, ids, m) } -// FlipObject 绕对象可见范围中心镜像。 +// FlipObject 绕对象可见范围中心镜像 // 入参: page 页面索引, id 对象标识, axis 为horizontal或vertical // 返回: error 错误信息 func (e *Editor) FlipObject(page int, id, axis string) error { return e.FlipObjects(page, []string{id}, axis) } -// FlipObjects 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部。 -// 以对象可见范围计算选区。 +// FlipObjects 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部 +// 以对象可见范围计算选区 // 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical // 返回: error 错误信息 func (e *Editor) FlipObjects(page int, ids []string, axis string) error { @@ -64,7 +64,7 @@ func (e *Editor) FlipObjects(page int, ids []string, axis string) error { return e.orientObjects(page, ids, m) } -// orientObjects 更新Boundary与CTM,不重排文字或重采样图片。 +// orientObjects 更新Boundary与CTM,不重排文字或重采样图片 // 入参: page 页面索引, ids 对象标识, matrix 直角旋转或镜像矩阵 // 返回: error 错误信息 func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error { @@ -110,7 +110,7 @@ func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error { return e.updateObjects(page, objects, true) } -// transformObjectClips 变换裁剪区域,不修改原始裁剪数据。 +// transformObjectClips 变换裁剪区域,不修改原始裁剪数据 // 入参: clips 裁剪集合, matrix 对象边界坐标中的变换 // 返回: *Clips 变换后的裁剪集合 func transformObjectClips(clips *Clips, matrix Matrix) *Clips { @@ -126,7 +126,7 @@ func transformObjectClips(clips *Clips, matrix Matrix) *Clips { return &result } -// ImageBounds 返回完整图片经Boundary位移及CTM变换后的轴对齐边界,不应用裁剪或父级变换。 +// ImageBounds 返回完整图片经Boundary位移及CTM变换后的轴对齐边界,不应用裁剪或父级变换 // 返回: Box 所在坐标系中的完整图片边界, error 错误信息 func (obj ImageObject) ImageBounds() (Box, error) { box, err := creationBox(obj.Boundary) @@ -142,8 +142,8 @@ func (obj ImageObject) ImageBounds() (Box, error) { return TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(obj.CTM)).TransformBox(Box{W: 1, H: 1}), nil } -// CropImage 按页面毫米坐标重设图片裁剪,保留原始资源及像素,可再次扩大裁剪区域。 -// 使用ImageObject.ImageBounds返回的范围可还原完整图片;提交一次撤销记录。 +// CropImage 按页面毫米坐标重设图片裁剪,保留原始资源及像素,可再次扩大裁剪区域 +// 使用ImageObject.ImageBounds返回的范围可还原完整图片;提交一次撤销记录 // 入参: page 页面索引, id 图片对象标识, box 页面毫米坐标中的保留范围 // 返回: error 错误信息 func (e *Editor) CropImage(page int, id string, box Box) error { @@ -161,7 +161,7 @@ func (e *Editor) CropImage(page int, id string, box Box) error { return e.UpdateObject(page, id, object) } -// cropImageObject 更新图片副本的裁剪和局部坐标,不修改资源。 +// cropImageObject 更新图片副本的裁剪和局部坐标,不修改资源 // 入参: image 图片对象副本, box 页面毫米坐标中的保留范围 // 返回: error 错误信息 func cropImageObject(image *ImageObject, box Box) error { @@ -200,8 +200,8 @@ func cropImageObject(image *ImageObject, box Box) error { return nil } -// FitImage 按原始像素比例居中适应或填充当前Boundary,保留直角旋转及镜像方向。 -// 保留原始图片,不重采样;重设裁剪,一次撤销恢复原布局。 +// FitImage 按原始像素比例居中适应或填充当前Boundary,保留直角旋转及镜像方向 +// 保留原始图片,不重采样;重设裁剪,一次撤销恢复原布局 // 入参: page 页面索引, id 图片对象标识, mode 为contain(完整显示)或cover(填满裁剪) // 返回: error 错误信息 func (e *Editor) FitImage(page int, id, mode string) error { @@ -218,7 +218,7 @@ func (e *Editor) FitImage(page int, id, mode string) error { return e.UpdateObject(page, id, object) } -// fitImage 将原始像素比例应用到当前图片框,适应时保留空白,填充时裁掉超出部分。 +// fitImage 将原始像素比例应用到当前图片框,适应时保留空白,填充时裁掉超出部分 // 入参: obj 图片对象副本, mode 为contain或cover // 返回: error 错误信息 func (e *Editor) fitImage(obj *ImageObject, mode string) error { @@ -263,8 +263,8 @@ func (e *Editor) fitImage(obj *ImageObject, mode string) error { return nil } -// TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界。 -// 直角旋转与翻转不改变本地段落宽度。 +// TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界 +// 直角旋转与翻转不改变本地段落宽度 // 返回: Box 对象本地坐标中的边界, error 错误信息 func (obj TextObject) TextFrame() (Box, error) { box, err := creationBox(obj.Boundary) @@ -283,8 +283,8 @@ func (obj TextObject) TextFrame() (Box, error) { return m.TransformBox(Box{W: box.W, H: box.H}), nil } -// ResizeTextFrame 调整文字本地坐标的左侧偏移及宽度,保留页面方向;重新排版后通过UpdateObject提交。 -// 支持轴向缩放、直角旋转与镜像,不支持斜切或其他角度旋转。 +// ResizeTextFrame 调整文字本地坐标的左侧偏移及宽度,保留页面方向;重新排版后通过UpdateObject提交 +// 支持轴向缩放、直角旋转与镜像,不支持斜切或其他角度旋转 // 入参: offset 本地左侧位移, width 新的本地排版宽度,单位为毫米 // 返回: TextObject 调整后的文字对象, error 错误信息 func (obj TextObject) ResizeTextFrame(offset, width float64) (TextObject, error) { @@ -308,7 +308,7 @@ func (obj TextObject) ResizeTextFrame(offset, width float64) (TextObject, error) return obj, nil } -// editorBoxString 使用统一精度序列化创作边界。 +// editorBoxString 使用统一精度序列化创作边界 // 入参: box 矩形范围 // 返回: string 标准Boundary属性值 func editorBoxString(box Box) string { diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index d23849a..e211c82 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -23,7 +23,7 @@ import ( "strings" ) -// editorXML 保存XML节点的原文位置,不重写未修改的内容。 +// editorXML 保存XML节点的原文位置,不重写未修改的内容 type editorXML struct { name xml.Name attrs []xml.Attr @@ -32,13 +32,13 @@ type editorXML struct { parent *editorXML } -// editorXMLPatch 替换原文中的连续区间。 +// editorXMLPatch 替换原文中的连续区间 type editorXMLPatch struct { start, end int data []byte } -// parseEditorXML 解析节点位置,保留命名空间、注释和原始格式。 +// parseEditorXML 解析节点位置,保留命名空间、注释和原始格式 // 入参: data XML原文 // 返回: *editorXML 根节点, error 错误信息 func parseEditorXML(data []byte) (*editorXML, error) { @@ -80,7 +80,7 @@ func parseEditorXML(data []byte) (*editorXML, error) { return root, nil } -// child 查找OFD直接子节点。 +// child 查找OFD直接子节点 // 入参: name 节点名称 // 返回: *editorXML 节点,不存在时为nil func (n *editorXML) child(name string) *editorXML { @@ -92,7 +92,7 @@ func (n *editorXML) child(name string) *editorXML { return nil } -// attr 获取无命名空间的属性值。 +// attr 获取无命名空间的属性值 // 入参: name 属性名称 // 返回: string 属性值 func (n *editorXML) attr(name string) string { @@ -104,7 +104,7 @@ func (n *editorXML) attr(name string) string { return "" } -// editorPatchXML 按位置应用互不重叠的替换。 +// editorPatchXML 按位置应用互不重叠的替换 // 入参: data 原文, patches 修改区间 // 返回: []byte 修改后的XML func editorPatchXML(data []byte, patches []editorXMLPatch) []byte { @@ -125,7 +125,7 @@ func editorPatchXML(data []byte, patches []editorXMLPatch) []byte { return result.Bytes() } -// editorXMLContent 替换节点内部内容,兼容自闭合节点。 +// editorXMLContent 替换节点内部内容,兼容自闭合节点 // 入参: data 原文, node 节点, content 新内容 // 返回: editorXMLPatch 修改区间 func editorXMLContent(data []byte, node *editorXML, content []byte) editorXMLPatch { @@ -141,7 +141,7 @@ func editorXMLContent(data []byte, node *editorXML, content []byte) editorXMLPat return editorXMLPatch{node.start, node.end, replacement} } -// editorXMLText 编码带独立命名空间的文本节点。 +// editorXMLText 编码带独立命名空间的文本节点 // 入参: name 节点名称, value 文本 // 返回: []byte XML片段 func editorXMLText(name, value string) []byte { @@ -150,7 +150,7 @@ func editorXMLText(name, value string) []byte { return []byte("" + escaped.String() + "") } -// editorXMLSetText 更新或追加直接子节点,保留其他节点。 +// editorXMLSetText 更新或追加直接子节点,保留其他节点 // 入参: data 原文, node 父节点, values 需要修改的字段 // 返回: []byte 修改后的XML func editorXMLSetText(data []byte, node *editorXML, values [][2]string) []byte { @@ -187,7 +187,7 @@ func editorXMLSetText(data []byte, node *editorXML, values [][2]string) []byte { return editorPatchXML(data, patches) } -// editorXMLObjects 递归登记图层和页块内的对象位置,保留原容器层级。 +// editorXMLObjects 递归登记图层和页块内的对象位置,保留原容器层级 // 入参: container 图层或页块, nodes 标识与节点映射 // 返回: error 错误信息 func editorXMLObjects(container *editorXML, nodes map[string]*editorXML) error { @@ -207,7 +207,7 @@ func editorXMLObjects(container *editorXML, nodes map[string]*editorXML) error { return nil } -// editorXMLAttributes 判断节点的命名空间与属性是否在编辑器支持范围内。 +// editorXMLAttributes 判断节点的命名空间与属性是否在编辑器支持范围内 // 入参: node 节点, allowed 允许的属性名称 // 返回: bool 是否支持 func editorXMLAttributes(node *editorXML, allowed string) bool { @@ -225,7 +225,7 @@ func editorXMLAttributes(node *editorXML, allowed string) bool { return true } -// editorXMLSupported 判断原文是否仅包含创建器能够保留的对象字段。 +// editorXMLSupported 判断原文是否仅包含创建器能够保留的对象字段 // 入参: node 对象或子节点 // 返回: bool 是否支持完整编辑 func editorXMLSupported(node *editorXML) bool { @@ -266,8 +266,8 @@ func editorXMLSupported(node *editorXML) bool { return true } -// editorXMLObject 修改对象发生变化的属性与内容,保留原文中的显式默认值。 -// 有效样式快照在修改后展开原绘制参数,避免已清除的属性重新继承。 +// editorXMLObject 修改对象发生变化的属性与内容,保留原文中的显式默认值 +// 有效样式快照在修改后展开原绘制参数,避免已清除的属性重新继承 // 入参: data 页面原文, node 原对象节点, before 原对象, after 新对象 // 返回: []byte 对象XML, error 错误信息 func editorXMLObject(data []byte, node *editorXML, before, after GraphicObject) ([]byte, error) { @@ -298,7 +298,7 @@ func editorXMLObject(data []byte, node *editorXML, before, after GraphicObject) return editorXMLMerge(data, node, oldXML, newXML) } -// editorXMLMerge 按标准编码的差异更新原节点,递归保留未变化的显式默认值与命名空间。 +// editorXMLMerge 按标准编码的差异更新原节点,递归保留未变化的显式默认值与命名空间 // 入参: data 原文, node 原节点, oldXML 修改前编码, newXML 修改后编码 // 返回: []byte 更新节点, error 错误信息 func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte, error) { @@ -397,7 +397,7 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte return result.Bytes(), nil } -// editorXMLContainer 用标准编码器封装已编码的子节点。 +// editorXMLContainer 用标准编码器封装已编码的子节点 // 入参: name 节点名称, attrs 属性, content 子节点XML // 返回: []byte XML片段, error 错误信息 func editorXMLContainer(name string, attrs ofdAttrs, content []byte) ([]byte, error) { @@ -410,7 +410,7 @@ func editorXMLContainer(name string, attrs ofdAttrs, content []byte) ([]byte, er return output.Bytes(), err } -// editorXMLStandalone 补齐原对象继承的命名空间,使跨页复制不依赖原父节点。 +// editorXMLStandalone 补齐原对象继承的命名空间,使跨页复制不依赖原父节点 // 入参: data 对象XML, source 原节点 // 返回: []byte 独立XML片段, error 错误信息 func editorXMLStandalone(data []byte, source *editorXML) ([]byte, error) { diff --git a/ofdgo_font_file.go b/ofdgo_font_file.go new file mode 100644 index 0000000..72f4ef3 --- /dev/null +++ b/ofdgo_font_file.go @@ -0,0 +1,112 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" + + "github.com/tdewolff/font" +) + +// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名 +type FontFace struct { + Index int `json:"index"` + FullName string `json:"fullName"` + Family string `json:"family"` + Style string `json:"style"` + PostScriptName string `json:"postscriptName"` + Names []string `json:"names"` +} + +// Faces 读取字体名称表,集合文件按原顺序列出各项,不解析字形轮廓 +// 返回: []FontFace 字体列表, error 格式或名称表错误 +func (f FontFile) Faces() ([]FontFace, error) { + data, err := font.ToSFNT(f.Data) + if err != nil { + return nil, err + } + count, err := fontFileCount(data) + if err != nil { + return nil, err + } + faces := make([]FontFace, 0, count) + for index := 0; index < count; index++ { + tables, err := fontFileTables(data, index) + if err != nil { + return nil, err + } + records, values := fontNameValues(tables["name"]) + name := func(ids ...uint16) string { + for _, id := range ids { + for _, record := range records { + if record.Name == id { + if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" { + return value + } + } + } + } + return "" + } + face := FontFace{Index: index, FullName: name(4, 6, 16, 1), Family: name(16, 1), Style: name(17, 2), PostScriptName: name(6), Names: fontNamesFromTable(tables["name"])} + if face.FullName == "" { + return nil, fmt.Errorf("font %d has no usable name", index) + } + faces = append(faces, face) + } + return faces, nil +} + +// Face 提取指定字体,返回独立的OpenType数据,可用于预览或AddFont,且不修改原文件 +// 入参: index 零起始字体索引,非集合文件只能为0 +// 返回: []byte 独立字体数据, error 格式或索引错误 +func (f FontFile) Face(index int) ([]byte, error) { + data, err := font.ToSFNT(f.Data) + if err != nil { + return nil, err + } + if bytes.HasPrefix(data, []byte("ttcf")) { + return extractCollectionFont(data, index) + } + if _, err := fontFileTables(data, index); err != nil { + return nil, err + } + return bytes.Clone(data), nil +} + +// fontFileCount 校验字体或集合头并读取字体数量 +// 入参: data OpenType数据 +// 返回: int 字体数量, error 格式错误 +func fontFileCount(data []byte) (int, error) { + if len(data) < 12 { + return 0, fmt.Errorf("invalid font header") + } + switch string(data[:4]) { + case "\x00\x01\x00\x00", "OTTO", "true": + return 1, nil + case "ttcf": + version := binary.BigEndian.Uint32(data[4:]) + count := uint64(binary.BigEndian.Uint32(data[8:])) + if (version != 0x00010000 && version != 0x00020000) || count == 0 || count > uint64(len(data)-12)/4 { + return 0, fmt.Errorf("invalid font collection header") + } + return int(count), nil + default: + return 0, fmt.Errorf("invalid OpenType header") + } +} diff --git a/ofdgo_font_name.go b/ofdgo_font_name.go index c3889c7..bbf4479 100644 --- a/ofdgo_font_name.go +++ b/ofdgo_font_name.go @@ -99,17 +99,42 @@ func fontFileNames(file io.ReaderAt) []string { // 入参: data 名称表数据 // 返回: []string 字体名称列表 func fontNamesFromTable(data []byte) []string { + records, values := fontNameValues(data) + var names []string + seen := make(map[string]bool) + for _, record := range records { + key := [4]uint16{record.Platform, record.Encoding, record.Language, record.Name} + name := values[key] + switch record.Name { + case 1, 16: + key[3]++ + if style := values[key]; style != "" && name != "" { + name += " " + style + } + case 4, 6: + default: + continue + } + names = appendFontName(names, seen, name) + } + return names +} + +// fontNameValues 解码字体名称表的多语言记录 +// 入参: data 名称表数据 +// 返回: []fontNameRecord 名称记录, map[[4]uint16]string 平台、编码、语言和名称对应的文本 +func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) { if len(data) < 6 || binary.BigEndian.Uint16(data) > 1 { - return nil + return nil, nil } count := int(binary.BigEndian.Uint16(data[2:])) storage := int(binary.BigEndian.Uint16(data[4:])) if 6+count*12 > storage || storage > len(data) { - return nil + return nil, nil } records := make([]fontNameRecord, count) if err := binary.Read(bytes.NewReader(data[6:]), binary.BigEndian, records); err != nil { - return nil + return nil, nil } values := make(map[[4]uint16]string) for _, record := range records { @@ -141,22 +166,5 @@ func fontNamesFromTable(data []byte) []string { } values[[4]uint16{record.Platform, record.Encoding, record.Language, record.Name}] = name } - var names []string - seen := make(map[string]bool) - for _, record := range records { - key := [4]uint16{record.Platform, record.Encoding, record.Language, record.Name} - name := values[key] - switch record.Name { - case 1, 16: - key[3]++ - if style := values[key]; style != "" && name != "" { - name += " " + style - } - case 4, 6: - default: - continue - } - names = appendFontName(names, seen, name) - } - return names + return records, values } diff --git a/ofdgo_font_util.go b/ofdgo_font_util.go index 697fc62..bd99c59 100644 --- a/ofdgo_font_util.go +++ b/ofdgo_font_util.go @@ -25,15 +25,33 @@ import ( // 入参: data 集合数据, index 字体索引 // 返回: []byte 单字体数据, error 错误信息 func extractCollectionFont(data []byte, index int) ([]byte, error) { - if len(data) < 12 || string(data[:4]) != "ttcf" { - return nil, fmt.Errorf("invalid font collection header") + tables, err := fontFileTables(data, index) + if err != nil { + return nil, err } - version := binary.BigEndian.Uint32(data[4:]) - count := uint64(binary.BigEndian.Uint32(data[8:])) - if (version != 0x00010000 && version != 0x00020000) || count > uint64(len(data)-12)/4 || index < 0 || uint64(index) >= count { - return nil, fmt.Errorf("invalid font collection index or version") + if len(tables["head"]) < 12 { + return nil, fmt.Errorf("invalid head font table") + } + tables["head"] = bytes.Clone(tables["head"]) + clear(tables["head"][8:12]) + return serializeOTF(tables) +} + +// fontFileTables 读取单字体或集合指定项的表目录,不复制表数据 +// 入参: data 字体数据, index 字体索引 +// 返回: map[string][]byte 字体表, error 错误信息 +func fontFileTables(data []byte, index int) (map[string][]byte, error) { + count, err := fontFileCount(data) + if err != nil { + return nil, err + } + if index < 0 || index >= count { + return nil, fmt.Errorf("invalid font index %d", index) + } + var offset uint64 + if string(data[:4]) == "ttcf" { + offset = uint64(binary.BigEndian.Uint32(data[12+index*4:])) } - offset := uint64(binary.BigEndian.Uint32(data[12+index*4:])) if offset > uint64(len(data)-12) { return nil, fmt.Errorf("invalid font offset") } @@ -60,12 +78,7 @@ func extractCollectionFont(data []byte, index int) ([]byte, error) { } tables[tag] = data[start : start+length] } - if len(tables["head"]) < 12 { - return nil, fmt.Errorf("invalid head font table") - } - tables["head"] = bytes.Clone(tables["head"]) - clear(tables["head"][8:12]) - return serializeOTF(tables) + return tables, nil } // packedGlyphRune 获取包装字体字符 @@ -537,7 +550,7 @@ func buildCmapTable(numGlyphs uint16, mapping map[rune]uint16) []byte { return mainBuf.Bytes() } -// shouldBuildCmapFormat12 判断字符超出BMP或format 4长度溢出时是否需要format 12 +// shouldBuildCmapFormat12 判断终止码、补充平面字符或format 4长度溢出时是否需要format 12 // 入参: mapping 字符到字形的映射 // 返回: bool 是否需要构建format 12子表 func shouldBuildCmapFormat12(mapping map[rune]uint16) bool { @@ -546,12 +559,10 @@ func shouldBuildCmapFormat12(mapping map[rune]uint16) bool { } var codes []int for r := range mapping { - if r > 0xFFFF { + if r >= 0xFFFF { return true } - if r != 0xFFFF { - codes = append(codes, int(r)) - } + codes = append(codes, int(r)) } if len(codes) == 0 { return false diff --git a/ofdgo_geom.go b/ofdgo_geom.go index 5a9444f..0ebbe48 100644 --- a/ofdgo_geom.go +++ b/ofdgo_geom.go @@ -27,6 +27,11 @@ type Box struct { X, Y, W, H float64 } +// Point 平面坐标点 +type Point struct { + X, Y float64 +} + // ParseBox 解析Box字符串 // 入参: s 字符串 // 返回: Box 矩形对象, error 错误信息 @@ -96,7 +101,7 @@ func (m Matrix) Transform(x, y float64) (float64, float64) { return nx, ny } -// TransformBox 获取矩形经仿射变换后的轴对齐边界。 +// TransformBox 获取矩形经仿射变换后的轴对齐边界 // 入参: box 原始矩形范围 // 返回: Box 变换后的轴对齐边界 func (m Matrix) TransformBox(box Box) Box { @@ -109,20 +114,20 @@ func (m Matrix) TransformBox(box Box) Box { return Box{X: left, Y: top, W: right - left, H: bottom - top} } -// String 返回标准CTM属性值。 +// String 返回标准CTM属性值 // 返回: string 六个空格分隔的矩阵分量 func (m Matrix) String() string { return fmt.Sprintf("%s %s %s %s %s %s", ofdNumber(m.a), ofdNumber(m.b), ofdNumber(m.c), ofdNumber(m.d), ofdNumber(m.e), ofdNumber(m.f)) } -// axisAlignedMatrix 判断变换是否保持坐标轴平行,含直角旋转与镜像。 +// axisAlignedMatrix 判断变换是否保持坐标轴平行,含直角旋转与镜像 // 入参: m 仿射变换矩阵 // 返回: bool 是否为非退化的轴对齐变换 func axisAlignedMatrix(m Matrix) bool { return m.b == 0 && m.c == 0 && m.a != 0 && m.d != 0 || m.a == 0 && m.d == 0 && m.b != 0 && m.c != 0 } -// matrixVector 变换位移,不包含平移分量。 +// matrixVector 变换位移,不包含平移分量 // 入参: m 仿射变换矩阵, x、y 原始位移 // 返回: float64 变换后的X位移, float64 变换后的Y位移 func matrixVector(m Matrix, x, y float64) (float64, float64) { diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index c6985f6..4c2bf71 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -21,10 +21,10 @@ import ( "github.com/tdewolff/canvas" ) -// ObjectBounds 获取文字、路径或图片在页面坐标中的轴对齐范围,不修改对象。 -// 文字采用字形范围,路径包含描边与裁剪,图片采用裁剪后的几何范围,不解码像素或排除透明像素。 -// 底纹按填充或描边轮廓度量,不展开图案单元。 -// 不应用页面边界或父级变换;无可见范围时返回零值,不支持的对象类型返回错误。 +// ObjectBounds 获取文字、路径或图片在页面坐标中的轴对齐范围,不修改对象 +// 文字采用字形范围,路径包含描边与裁剪,图片采用裁剪后的几何范围,不解码像素或排除透明像素 +// 底纹按填充或描边轮廓度量,不展开图案单元 +// 不应用页面边界或父级变换;无可见范围时返回零值,不支持的对象类型返回错误 // 入参: object 图形对象, drawParam 图层绘制参数标识,无继承时为空 // 返回: Box 毫米坐标范围, error 错误信息 func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, error) { @@ -94,7 +94,7 @@ func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, er return bounds.box, nil } -// boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷。 +// boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷 // 入参: color 填充或描边颜色 // 返回: *FillColor 用于度量的颜色 func boundsColor(color *FillColor) *FillColor { @@ -104,18 +104,18 @@ func boundsColor(color *FillColor) *FillColor { return &FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha} } -// boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓。 +// boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓 type boundsRenderer struct { box Box } -// Size 返回不限定边界的度量画布尺寸。 +// Size 返回不限定边界的度量画布尺寸 // 返回: float64 宽度, float64 高度 func (r *boundsRenderer) Size() (float64, float64) { return 0, 0 } -// add 合并路径的精确曲线范围并转换为向下的纵轴。 +// add 合并路径的精确曲线范围并转换为向下的纵轴 // 入参: path 绘制路径 func (r *boundsRenderer) add(path *canvas.Path) { if path.Empty() { @@ -125,7 +125,7 @@ func (r *boundsRenderer) add(path *canvas.Path) { r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) } -// RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义。 +// RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义 // 入参: path 路径, style 绘制样式, m 变换矩阵 func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { if style.HasFill() { @@ -137,12 +137,12 @@ func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m can } } -// RenderText 收集绘制文字范围。 +// RenderText 收集绘制文字范围 // 入参: text 文字, m 变换矩阵 func (r *boundsRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { text.RenderTo(r, m, 0) } -// RenderImage 度量不展开底纹图片。 +// RenderImage 度量不展开底纹图片 // 入参: img 图片, m 变换矩阵 func (r *boundsRenderer) RenderImage(img image.Image, m canvas.Matrix) {} diff --git a/ofdgo_renderer_image.go b/ofdgo_renderer_image.go index da61f32..a0845fb 100644 --- a/ofdgo_renderer_image.go +++ b/ofdgo_renderer_image.go @@ -171,7 +171,7 @@ func imageWithMask(img, mask image.Image) image.Image { } // imageWithClip 应用图片裁剪区域 -// 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率。 +// 低分辨率图片按最低精度细化蒙版,细化后的像素上限16Mi,不降低原图分辨率 // 入参: img 图片对象, clipPath 裁剪路径, m 图片变换矩阵, dpi 最低蒙版分辨率,0保留原图精度 // 返回: image.Image 裁剪后的图片对象 func imageWithClip(img image.Image, clipPath *canvas.Path, m canvas.Matrix, dpi float64) image.Image { diff --git a/ofdgo_shape.go b/ofdgo_shape.go index 1efa01f..53b0ad1 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -30,9 +30,9 @@ const ( ShapeEllipse ShapeKind = "ellipse" ) -// NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充。 -// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入。 -// 水平、垂直直线使用正尺寸边界,路径端点不变。 +// NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充 +// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入 +// 水平、垂直直线使用正尺寸边界,路径端点不变 // 入参: kind 图形类型, box 图形范围;直线从 (X,Y) 到 (X+W,Y+H),W、H 可为负或零 // 返回: PathObject 路径对象, error 错误信息 func NewShape(kind ShapeKind, box Box) (PathObject, error) { @@ -65,8 +65,8 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) { return object, nil } -// Shape 识别 NewShape 生成的基本路径及其轴对齐缩放、直角旋转、镜像和平移。 -// 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型。 +// Shape 识别 NewShape 生成的基本路径及其轴对齐缩放、直角旋转、镜像和平移 +// 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型 // 返回: ShapeKind 图形类型, Box 几何范围(不含描边) func (p PathObject) Shape() (ShapeKind, Box) { tokens := strings.Fields(p.AbbreviatedData) @@ -126,7 +126,7 @@ func (p PathObject) Shape() (ShapeKind, Box) { return kind, box } -// sameShapePath 比较基本路径的命令与数值,忽略数值格式差异。 +// sameShapePath 比较基本路径的命令与数值,忽略数值格式差异 // 入参: a、b 路径词元 // 返回: bool 是否相同 func sameShapePath(a, b []string) bool { @@ -146,8 +146,8 @@ func sameShapePath(a, b []string) bool { return true } -// Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、方向、缩放和绘制属性,不缩放线宽。 -// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径。 +// Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、方向、缩放和绘制属性,不缩放线宽 +// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径 // 入参: box 新几何范围,直线使用起点和有符号的端点位移 // 返回: PathObject 调整后的对象, error 错误信息 func (p PathObject) Reshape(box Box) (PathObject, error) { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 656aab6..42d0717 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -30,8 +30,9 @@ import ( const ofdNamespace = "http://www.ofdspec.org/2016" // WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出 -// 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集;编辑资源、原文档字体、可变及彩色字体等保持原样。 -// 重开子集文档后输入未包含的文字,需要通过AddFont注册完整字体并替换原字体引用。 +// 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集 +// 原有字体在全包引用可确定时保留字形编号裁剪;编辑资源、复杂字体及无法确定的引用保持原样 +// 重开子集文档后输入未包含的文字,需要通过AddFont注册完整字体并替换原字体引用 // 入参: writer 输出流 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WriteTo(writer io.Writer) (int64, error) { @@ -207,7 +208,7 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error { return nil } -// usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池。 +// usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池 // 返回: []editorResource 新增字体, []editorResource 新增图片, []string 原资源文件 func (e *Editor) usedResources() (fonts, images []editorResource, sourceFiles []string) { used := make(map[string]bool) @@ -282,7 +283,7 @@ func (x *ofdXML) page(page PageContent) { x.end("Page") } -// layer 写出新建图层及其对象。 +// layer 写出新建图层及其对象 // 入参: layer 图层 func (x *ofdXML) layer(layer Layer) { var attrs ofdAttrs