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