diff --git a/assets/webui/index.html b/assets/webui/index.html index 0947ab8..0b899df 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -100,7 +100,8 @@ 插入
-
+
+
pt
文字 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index b3b9b67..f2333fd 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -208,6 +208,78 @@ a:focus-visible { min-width: 0; } +.font-picker { + display: flex; + min-width: 0; + height: var(--control-height); + border: 1px solid var(--line); + background: #fff; +} + +.font-picker #textFont { + padding: 0 6px; + border: 0; + outline: 0; + background: transparent; + color: #1f2428; +} + +.font-picker:focus-within { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +#textFontToggle { + width: 24px; + min-width: 24px; + padding: 0; + border: 0; +} + +#textFontToggle::after { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + transform: translateY(-2px) rotate(45deg); +} + +.font-listbox { + position: fixed; + margin: 0; + padding: 4px; + border: 1px solid var(--line); + background: var(--panel); + color: #1f2428; + box-shadow: 0 5px 18px rgba(20, 40, 50, 0.14); + overflow-y: auto; + font-size: 14px; +} + +.font-listbox [role="option"] { + padding: 6px 8px; + line-height: 1.4; + overflow-wrap: anywhere; + cursor: pointer; +} + +.font-listbox [role="option"]:hover, +.font-listbox [aria-selected="true"] { + background: var(--accent-soft); + color: var(--accent); +} + +.font-listbox [aria-disabled="true"], +.font-search-empty { + color: var(--text-muted); +} + +.font-search-empty { + padding: 6px 8px; +} + #textColor { width: 100%; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index f57839a..7a54511 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,5 +1,5 @@ import { CanvasEditor, canEditObject, objectEditReason } from "./ofdgo_edit.js"; -import { FontManager } from "./ofdgo_font.js"; +import { FontManager, FontPicker } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; const COMPACT_LAYOUT = window.matchMedia("(max-width: 900px)"); @@ -109,6 +109,8 @@ const el = { textLineHeight: document.querySelector("#textLineHeight"), textSpacing: document.querySelector("#textSpacing"), textFont: document.querySelector("#textFont"), + textFontToggle: document.querySelector("#textFontToggle"), + textFontList: document.querySelector("#textFontList"), textFontAdd: document.querySelector("#textFontAdd"), textSize: document.querySelector("#textSize"), textColor: document.querySelector("#textColor"), @@ -244,9 +246,11 @@ const fontManager = new FontManager({ onChange: scheduleFontSync, onPermissionChange: updateFontPermissionHint, }); +const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont); +el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - fontControls: [el.textFont, el.textFontAdd], + fontControls: [el.textFont, el.textFontToggle, el.textFontAdd], busy: () => document.body.hasAttribute("aria-busy"), rotation: () => state.rotation, canInsert: (index) => pageCan("insert", index), @@ -341,7 +345,6 @@ editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); el.textAlign.addEventListener("change", () => changeParagraph()); el.textLineHeight.addEventListener("change", () => changeParagraph()); el.textSpacing.addEventListener("change", () => changeParagraph()); -el.textFont.addEventListener("change", changeTextFont); el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) { input.addEventListener("keydown", (event) => { @@ -942,7 +945,7 @@ async function toggleTextTool() { } if (openSeq !== state.openSeq) return; const style = canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults; - const font = state.textFonts[Number(el.textFont.value)]; + const font = state.textFonts[Number(fontPicker.value)]; state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap, align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0, fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice }; @@ -953,7 +956,7 @@ async function toggleTextTool() { } async function beginCanvasText(index, box, surface, page) { - const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)]; + const font = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)]; if (!font || font.disabled) { setStatus("尚未添加字体"); el.textFontAdd.focus(); @@ -984,19 +987,6 @@ function editorFonts(item) { return fonts; } -function setFontOptions(select, fonts, selected) { - const key = (font) => font?.id || font?.postscriptName; - select.replaceChildren(); - fonts.forEach((font, index) => { - const option = document.createElement("option"); - option.value = String(index); - option.textContent = font.fullName || font.name; - option.disabled = Boolean(font.disabled); - select.append(option); - }); - select.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : ""; -} - function updateTextFonts(item, refresh = false) { const id = item?.type === "TextObject" && !item.draft ? item.font : null; if (refresh || state.textFontID !== id) { @@ -1007,14 +997,14 @@ function updateTextFonts(item, refresh = false) { && !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) { state.textFonts.unshift(selected); } - setFontOptions(el.textFont, state.textFonts, selected || state.textFonts[0]); - if (!id) state.textDefaults.fontChoice = state.textFonts[Number(el.textFont.value)]; + fontPicker.setFonts(state.textFonts, selected || state.textFonts[0]); + if (!id) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)]; } } async function changeTextFont() { const item = canvasEditor.selected; - const font = state.textFonts[Number(el.textFont.value)]; + const font = state.textFonts[Number(fontPicker.value)]; if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return; const editing = canvasEditor.input; if (editing) { @@ -2992,7 +2982,7 @@ async function pasteEditorContent(event) { } const value = event.clipboardData.getData("text/plain").replace(/\t/g, " "); if (!value.trim()) return; - const font = state.textFonts[Number(el.textFont.value)]; + const font = state.textFonts[Number(fontPicker.value)]; if (!font || font.disabled) { setStatus("尚未添加字体"); el.textFontAdd.focus(); @@ -4459,7 +4449,7 @@ function updateObjectControls(item, reset = false) { el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const text = item?.type === "TextObject" ? item : state.textDefaults; const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items); - el.textFont.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"); + fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); el.textSize.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "reflow"); el.textColor.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "update"); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; @@ -4495,12 +4485,12 @@ function updateObjectControls(item, reset = false) { } updateDrawingControls(); const members = item?.items || (item ? [item] : []); - const orders = members.map(member => member.order).sort((a, b) => a - b); + const orders = members.map(member => member.position).sort((a, b) => a - b); const count = members[0]?.count || 0; const canRaise = orders.some((order, i) => order !== count - orders.length + i); const canLower = orders.some((order, i) => order !== i); el.objectOrder.disabled = disabled || cropping || count <= orders.length || !canEditObject(item, "order") - || members.some(member => member.layer !== members[0].layer); + || members.some(member => member.container !== members[0].container); for (const option of el.objectOrder.options) { option.disabled = el.objectOrder.disabled || (["up", "top"].includes(option.value) ? !canRaise : !canLower); } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 6c71c2e..93edf3d 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -156,9 +156,9 @@ export class CanvasEditor { viewer.addEventListener("lostpointercapture", () => this.cancel()); viewer.addEventListener("keydown", (event) => this.keyDown(event)); viewer.addEventListener("keyup", (event) => this.modifierChange(event)); - viewer.addEventListener("dblclick", () => { + viewer.addEventListener("dblclick", (event) => { const item = this.selected; - if (this.enabled && item && !item.items && !this.input && !this.options.busy()) { + if (!event.altKey && this.enabled && item && !item.items && !this.input && !this.options.busy()) { this.options.onEdit(item); } }); @@ -361,6 +361,10 @@ export class CanvasEditor { this.startShape(event); return; } + if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.target.dataset.corner) { + this.cycleSelection(event); + return; + } const node = event.target.closest(".edit-object, .edit-selection"); if (node) { event.preventDefault(); @@ -401,6 +405,19 @@ export class CanvasEditor { this.viewer.setPointerCapture(event.pointerId); } + cycleSelection(event) { + const surface = event.target.closest(".page-surface"); + const page = this.pages.get(surface); + if (!page) return; + const point = pagePoint(event.clientX, event.clientY, surface.getBoundingClientRect(), page, this.options.rotation()); + const items = [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse() + .filter(item => point.x >= item.x && point.x <= item.x + item.width && point.y >= item.y && point.y <= item.y + item.height); + if (!items.length) return; + event.preventDefault(); + this.viewer.focus({ preventScroll: true }); + this.select(items[(items.indexOf(this.selected) + 1) % items.length]); + } + move(event) { const drag = this.drag; if (!drag || drag.pointerID !== event.pointerId) { diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 7321006..b0cf58d 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -1,5 +1,141 @@ const DATABASE = "ofdgo"; +export class FontPicker { + constructor(input, toggle, list, onChange) { + Object.assign(this, { input, toggle, list, onChange, fonts: [], value: "", active: -1, open: false }); + input.addEventListener("input", () => this.show(input.value)); + input.addEventListener("focus", () => input.select()); + input.addEventListener("click", () => { if (!this.open) this.show(); }); + input.addEventListener("keydown", event => this.keyDown(event)); + input.addEventListener("blur", () => { this.commitExact(); this.close(); }); + toggle.addEventListener("pointerdown", event => event.preventDefault()); + toggle.addEventListener("click", () => { + if (this.open) this.close(); + else { input.focus(); this.show(); } + }); + list.addEventListener("pointerdown", event => { + if (event.target.closest("[role=option]")) event.preventDefault(); + }); + list.addEventListener("click", event => { + const option = event.target.closest("[role=option]"); + if (option) this.choose(Number(option.dataset.index)); + }); + window.addEventListener("resize", () => this.close()); + } + + setFonts(fonts, selected) { + this.close(); + this.fonts = fonts; + const key = font => font?.id || font?.postscriptName; + this.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : ""; + this.restore(); + } + + setDisabled(disabled) { + this.input.disabled = this.toggle.disabled = disabled; + if (disabled) this.close(); + } + + restore() { + const font = this.fonts[Number(this.value)]; + this.input.value = font?.fullName || font?.name || ""; + this.input.setAttribute("aria-expanded", String(this.open)); + this.input.removeAttribute("aria-activedescendant"); + } + + show(query = "") { + if (this.input.disabled) return; + 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(" ")); + return terms.every(term => name.includes(term)); + }); + this.list.replaceChildren(); + for (const { font, index } of this.matches) { + const option = document.createElement("div"); + option.id = `${this.list.id}-${index}`; + option.dataset.index = String(index); + option.setAttribute("role", "option"); + option.setAttribute("aria-disabled", String(Boolean(font.disabled))); + option.textContent = font.fullName || font.name; + this.list.append(option); + } + if (!this.matches.length) { + const empty = document.createElement("div"); + empty.className = "font-search-empty"; + empty.textContent = "无匹配字体"; + this.list.append(empty); + } + if (!this.open) this.list.showPopover(); + this.open = true; + this.input.setAttribute("aria-expanded", "true"); + this.position(); + const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value); + this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected); + } + + position() { + if (!this.open) return; + const rect = this.input.getBoundingClientRect(); + if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; } + const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16); + Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`, + top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` }); + } + + activate(index) { + this.active = index; + [...this.list.children].forEach((option, i) => option.setAttribute("aria-selected", String(i === index))); + const option = this.list.children[index]; + if (option) { + this.input.setAttribute("aria-activedescendant", option.id); + option.scrollIntoView({ block: "nearest" }); + } else this.input.removeAttribute("aria-activedescendant"); + } + + choose(index) { + if (!this.fonts[index] || this.fonts[index].disabled) return; + const changed = this.value !== String(index); + this.value = String(index); + this.close(); + if (changed) return this.onChange(); + } + + 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)); + if (index >= 0) this.choose(index); + } + + close() { + if (this.open) { this.open = false; this.list.hidePopover(); } + this.restore(); + } + + keyDown(event) { + if (event.isComposing) return; + if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return; + event.stopPropagation(); + if (event.key === "Escape") { + event.preventDefault(); + this.close(); + } else if (event.key === "Enter") { + event.preventDefault(); + if (this.open && this.active >= 0) this.choose(this.matches[this.active].index); + else { this.commitExact(); this.close(); } + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (!this.open) { this.show(); return; } + const direction = event.key === "ArrowDown" ? 1 : -1; + for (let i = this.active + direction; i >= 0 && i < this.matches.length; i += direction) { + if (!this.matches[i].font.disabled) { this.activate(i); break; } + } + } else if (event.key === "Tab") { this.commitExact(); this.close(); } + } +} + export class FontManager { constructor({ onChange, onPermissionChange }) { this.userFonts = []; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 0a991a0..e2233a3 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -662,7 +662,11 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { continue } if box.W > 0 && box.H > 0 { - item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects), "layer": layer.ID, + position, err := currentEditor.ObjectPosition(index, id) + if err != nil { + return nil, err + } + item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, "capabilities": map[string]any{"update": capability.Update, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason}} if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() diff --git a/ofdgo_editor.go b/ofdgo_editor.go index dcd2e95..314f77a 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -428,15 +428,17 @@ func (e *Editor) DeleteObject(page int, id string) error { return e.DeleteObjects(page, []string{id}) } -// MoveObject 调整同页正文图层内的绘制顺序,保留对象标识和内容 -// 入参: page 页面索引, id 对象标识, to 移动后的对象索引,0为最底层,末尾为最顶层 +// MoveObject 调整同一图层或末级页块内的绘制顺序,保留对象标识和内容 +// 入参: page 页面索引, id 对象标识, to 直接容器内的对象索引,0为最底层,末尾为最顶层 // 返回: error 错误信息 func (e *Editor) MoveObject(page int, id string, to int) error { layer, from, err := e.findObject(page, id) if err != nil { return err } - if to < 0 || to >= len(layer.Objects) { + _, siblings := e.objectOrderIndexes(page, layer, id) + from = slices.Index(siblings, from) + if to < 0 || to >= len(siblings) { return fmt.Errorf("object index %d out of range", to) } if from == to { @@ -452,7 +454,14 @@ func (e *Editor) MoveObject(page int, id string, to int) error { layers := copyEditorPage(e.pages[page]).Content.Layer for i := range layers { if layers[i].ID == layer.ID { - moveEditorItem(layers[i].Objects, from, to) + objects := make([]GraphicObject, len(siblings)) + for j, index := range siblings { + objects[j] = layer.Objects[index] + } + moveEditorItem(objects, from, to) + for j, index := range siblings { + layers[i].Objects[index] = objects[j] + } } } e.replaceLayers(page, layers) diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index f07c16b..005770f 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -132,7 +132,59 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) return result, nil } -// OrderObjects 调整同页选区的绘制顺序,保留选中及未选中对象各自的相对顺序。 +// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象。 +type ObjectPosition struct { + Layer string + Container string + Index int + Count int +} + +// ObjectPosition 获取对象的直接容器及排序位置。 +// 入参: page 页面索引, id 对象标识 +// 返回: ObjectPosition 对象位置, error 错误信息 +func (e *Editor) ObjectPosition(page int, id string) (ObjectPosition, error) { + layer, _, err := e.findObject(page, id) + if err != nil { + return ObjectPosition{}, err + } + parent, indexes := e.objectOrderIndexes(page, layer, id) + position := ObjectPosition{Layer: layer.ID, Container: layer.ID, Count: len(indexes)} + if parent != nil { + position.Container = parent.attr("ID") + } + for i, index := range indexes { + if editorObjectID(layer.Objects[index]) == id { + position.Index = i + break + } + } + return position, nil +} + +// objectOrderIndexes 获取同一直接容器的对象索引,不跨越图层或页块。 +// 入参: page 页面索引, layer 图层, id 对象标识 +// 返回: *editorXML 原始容器, []int 对象索引 +func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorXML, []int) { + var nodes map[string]*editorXML + var parent *editorXML + if e.originalPage(page) { + nodes = e.source.pages[e.pages[page].ID].nodes + if node := nodes[id]; node != nil { + parent = node.parent + } + } + var indexes []int + for i, object := range layer.Objects { + node := nodes[editorObjectID(object)] + if node == nil && parent == nil || node != nil && node.parent == parent { + indexes = append(indexes, i) + } + } + return parent, indexes +} + +// OrderObjects 调整同一图层或页块内选区的绘制顺序,保留各组选中及未选中对象的相对顺序。 // 入参: page 页面索引, ids 对象标识, order 为up、down、top或bottom // 返回: error 错误信息 func (e *Editor) OrderObjects(page int, ids []string, order string) error { @@ -143,20 +195,25 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error { if err != nil || len(indexes) == 0 { return err } + layer := &e.pages[page].Content.Layer[indexes[0].layer] + _, siblings := e.objectOrderIndexes(page, layer, ids[0]) for i, id := range ids { capability, err := e.ObjectCapabilities(page, id) if err != nil { return err } - if !capability.Order || indexes[i].layer != indexes[0].layer { - return fmt.Errorf("ordering requires editable objects in the same layer") + if !capability.Order || indexes[i].layer != indexes[0].layer || !slices.Contains(siblings, indexes[i].index) { + return fmt.Errorf("ordering requires editable objects in the same container") } } layers := copyEditorPage(e.pages[page]).Content.Layer - objects := layers[indexes[0].layer].Objects + objects := make([]GraphicObject, len(siblings)) + for i, index := range siblings { + objects[i] = layer.Objects[index] + } selected := make([]bool, len(objects)) for _, index := range indexes { - selected[index.index] = true + selected[slices.Index(siblings, index.index)] = true } switch order { case "up": @@ -184,7 +241,9 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error { } objects = ordered } - layers[indexes[0].layer].Objects = objects + for i, index := range siblings { + layers[indexes[0].layer].Objects[index] = objects[i] + } e.replaceLayers(page, layers) return nil } diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go new file mode 100644 index 0000000..953b4e8 --- /dev/null +++ b/ofdgo_editor_font.go @@ -0,0 +1,97 @@ +// 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" + "fmt" + "slices" + + "github.com/tdewolff/font" +) + +// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,不修改编辑资源或原文档字体。 +// 返回: map[string][]byte 包内字体子集, error 错误信息 +func (e *Editor) subsetFonts() (map[string][]byte, error) { + used := make(map[string]map[uint16]bool) + for _, resource := range e.resources { + if resource.font == nil { + continue + } + sfnt := e.fonts[resource.font.ID] + if sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) { + used[resource.font.ID] = make(map[uint16]bool) + } + } + for _, page := range e.pages { + for _, layer := range page.Content.Layer { + for _, object := range layer.Objects { + if object.Type != "TextObject" { + continue + } + obj := object.TextObject + glyphs := used[obj.Font] + if glyphs == nil { + continue + } + for _, code := range obj.TextCode { + for _, char := range textCodeRunes(code.Value) { + glyphs[e.fonts[obj.Font].GlyphIndex(char)] = true + } + } + } + } + } + result := make(map[string][]byte) + for _, resource := range e.resources { + if resource.font == nil || len(used[resource.font.ID]) == 0 { + continue + } + glyphs := used[resource.font.ID] + glyphs[0] = true + ids := make([]uint16, 0, len(glyphs)) + for id := range glyphs { + ids = append(ids, id) + } + slices.Sort(ids) + data, err := subsetEditorFont(resource.data, ids) + if err != nil { + return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err) + } + if len(data) < len(resource.data) { + result[resource.name] = data + } + } + return result, nil +} + +// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令。 +// 入参: data 字体数据, glyphs 已排序的字形编号,包含0 +// 返回: []byte 字体子集, error 错误信息 +func subsetEditorFont(data []byte, glyphs []uint16) ([]byte, error) { + sfnt, err := font.ParseSFNT(bytes.Clone(data), 0) + if err != nil { + return nil, err + } + subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{ + "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", + }}) + if err != nil { + return nil, err + } + subset.Tables["name"] = sfnt.Tables["name"] + clear(subset.Tables["head"][8:12]) + return serializeOTF(subset.Tables) +} diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index b706054..dcd3bd9 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -57,7 +57,7 @@ type editorSourcePage struct { } // ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因。 -// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限当前图层。 +// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块。 // Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复。 type ObjectCapabilities struct { Update bool @@ -348,7 +348,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er if err := validateEditorGeometry(object); err != nil { return ObjectCapabilities{Reason: err.Error()}, nil } - all.Order = e.sourceLayerOrderable(page, layer.ID) + all.Order = editorContainerOrderable(node.parent) if _, err := e.prepareObject(id, object); err != nil { all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error() } @@ -375,23 +375,13 @@ func (e *Editor) objectOrigin(id string) *editorObjectOrigin { return e.source.origins[id] } -// sourceLayerOrderable 判断图层是否仅含可独立排序的直接对象。 -// 入参: page 页面索引, id 图层标识 +// editorContainerOrderable 判断容器的直接对象能否在保留页块的前提下排序。 +// 入参: container 图层或页块 // 返回: bool 是否可排序 -func (e *Editor) sourceLayerOrderable(page int, id string) bool { - if !e.originalPage(page) { - return true - } - content := e.source.pages[e.pages[page].ID].root.child("Content") - if content != nil { - for _, layer := range content.children { - if layer.attr("ID") == id { - for _, child := range layer.children { - if !editorXMLSupported(child) { - return false - } - } - } +func editorContainerOrderable(container *editorXML) bool { + for _, child := range container.children { + if !editorXMLSupported(child) { + return false } } return true diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 19b5a22..761117e 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -214,17 +214,20 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err for _, object := range original.Objects { before[editorObjectID(object)] = object } + ordered := make(map[*editorXML][]GraphicObject) after := make(map[string]GraphicObject) - var ordered []GraphicObject for _, object := range current.Objects { id := editorObjectID(object) after[id] = object - if source.nodes[id] != nil { - ordered = append(ordered, object) + if node := source.nodes[id]; node != nil { + ordered[node.parent] = append(ordered[node.parent], object) } } - orderable := e.sourceLayerOrderable(index, original.ID) - position := 0 + positions := make(map[*editorXML]int) + orderable := make(map[*editorXML]bool) + for parent := range ordered { + orderable[parent] = editorContainerOrderable(parent) + } for _, object := range original.Objects { id := editorObjectID(object) node := source.nodes[id] @@ -232,12 +235,14 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err continue } next, exists := after[id] - if orderable { - exists = position < len(ordered) + if orderable[node.parent] { + position := positions[node.parent] + members := ordered[node.parent] + exists = position < len(members) if exists { - next = ordered[position] + next = members[position] } - position++ + positions[node.parent]++ } var encoded []byte if exists { @@ -369,13 +374,14 @@ func (e *Editor) sourceInfoXML() ([]byte, error) { } // writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本。 -// 入参: writer 输出流 +// 入参: writer 输出流, fonts 新增字体子集 // 返回: int64 写入字节数, error 错误信息 -func (e *Editor) writeSource(writer io.Writer) (int64, error) { +func (e *Editor) writeSource(writer io.Writer, fonts map[string][]byte) (int64, error) { parts, err := e.sourceParts() if err != nil { return 0, err } + maps.Copy(parts, fonts) reader := e.source.reader remaining := maps.Clone(reader.files) if remaining == nil { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index abc21fd..eedf99d 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -29,19 +29,28 @@ import ( const ofdNamespace = "http://www.ofdspec.org/2016" -// WriteTo 逐个条目写出OFD,仅包含实际引用的资源,不关闭调用方输出流,出错时应丢弃本次输出 +// WriteTo 逐个条目写出OFD,不关闭调用方输出流,出错时应丢弃本次输出 +// 新增资源仅写入实际引用的部分,静态TrueType轮廓字体按实际文字生成子集;编辑资源、原文档字体、可变及彩色字体等保持原样。 +// 重开子集文档后输入未包含的文字,需要通过AddFont注册完整字体并替换原字体引用。 // 入参: writer 输出流 // 返回: int64 已写入字节数, error 错误信息 func (e *Editor) WriteTo(writer io.Writer) (int64, error) { if err := e.validate(); err != nil { return 0, err } + fonts, err := e.subsetFonts() + if err != nil { + return 0, err + } if e.source != nil { - return e.writeSource(writer) + return e.writeSource(writer, fonts) } output := &ofdCountingWriter{writer: writer} archive := zip.NewWriter(output) - err := e.writeParts(func(name string, data []byte, compressed bool) error { + err = e.writeParts(func(name string, data []byte, compressed bool) error { + if subset, ok := fonts[name]; ok { + data = subset + } method := uint16(zip.Deflate) if compressed { method = zip.Store @@ -60,7 +69,7 @@ func (e *Editor) WriteTo(writer io.Writer) (int64, error) { } // Reader 获取当前文档的独立内存快照,不进行ZIP压缩,后续修改不影响已有快照 -// 返回的Reader可用于现有渲染、搜索和导出接口,仅包含实际引用的资源,二进制数据内部共享只读 +// 返回的Reader可用于现有渲染、搜索和导出接口,新增资源仅包含实际引用的部分,二进制数据内部共享只读 // 返回: *Reader 阅读器, error 错误信息 func (e *Editor) Reader() (*Reader, error) { if err := e.validate(); err != nil {