Compare commits

...
47 Commits
Author SHA1 Message Date
xiaoqidun 838a769c5b fix(编辑能力): 修复输入状态下标签快捷键
build.yaml / build (push) Successful in 47s
2026-10-29 15:01:52 +08:00
xiaoqidun 5b4f2472f7 fix(编辑能力): 修复快捷键遗漏及跨文档干扰
build.yaml / build (push) Successful in 45s
2026-10-29 14:52:40 +08:00
xiaoqidun fb51fd3b07 fix(编辑能力): 修复工具栏复制剪切粘贴失效
build.yaml / build (push) Successful in 45s
2026-10-29 14:30:58 +08:00
xiaoqidun 80e548f93e fix(编辑能力): 修复工具栏撤销快捷键失效
build.yaml / build (push) Successful in 48s
2026-10-29 14:16:31 +08:00
xiaoqidun 9c3a04b7eb feat(编辑能力): 优化分布操作,提前禁用无效方向
build.yaml / build (push) Successful in 46s
2026-10-29 13:54:58 +08:00
xiaoqidun 9bb6c9141a feat(基础镜像): 升级到alpine:3.24.3
build.yaml / build (push) Successful in 44s
2026-10-29 08:46:31 +08:00
xiaoqidun 4320b53d4a fix(字体处理): 修复字体补全误配与共享释放
build.yaml / build (push) Successful in 49s
2026-10-28 22:07:10 +08:00
xiaoqidun e5d7f577c7 fix(字体处理): 修复字体续建、替换与复制异常 2026-10-28 21:59:44 +08:00
xiaoqidun 7a23dcc6dd feat(字体处理): 统一字体名称,优化粗斜体选择
build.yaml / build (push) Successful in 54s
2026-10-28 21:33:26 +08:00
xiaoqidun 12aaada982 fix(编辑能力): 修复字号间距字体等提交丢失
build.yaml / build (push) Successful in 45s
2026-10-28 20:00:15 +08:00
xiaoqidun 62631472ea fix(编辑能力): 修复底色及其他颜色提交异常
build.yaml / build (push) Successful in 47s
2026-10-28 19:23:09 +08:00
xiaoqidun 9d6bf3e87f feat(字体选择): 默认字体优先中文
build.yaml / build (push) Successful in 47s
2026-10-28 18:50:10 +08:00
xiaoqidun f8c8259502 feat(字体处理): 优化字体预加载与搜索选中行为
build.yaml / build (push) Successful in 47s
2026-10-28 16:43:04 +08:00
xiaoqidun f4429b126a fix(编辑能力): 修复字体引用隔离与资源释放
build.yaml / build (push) Successful in 49s
2026-10-28 16:07:41 +08:00
xiaoqidun 50898934dc fix(编辑能力): 修复混排字体选择与统一 2026-10-28 14:22:29 +08:00
xiaoqidun c2b0339fdf feat(编辑能力): 统一文字格式与段落编辑
build.yaml / build (push) Successful in 46s
2026-10-28 14:10:32 +08:00
xiaoqidun e5658de69d fix(编辑能力): 修正文字缩放、拖动及组合排版
build.yaml / build (push) Successful in 42s
2026-10-28 13:28:33 +08:00
xiaoqidun 057bcba868 fix(编辑能力): 修正文字编辑,统一元数据命名
build.yaml / build (push) Successful in 48s
2026-10-28 13:05:06 +08:00
xiaoqidun 89a7ef04c7 feat(编辑能力): 统一文字编辑,完善排版保存
build.yaml / build (push) Successful in 43s
2026-10-28 12:44:31 +08:00
xiaoqidun e35533859c fix(编辑能力): 修正文字边框与改宽手柄错位 2026-10-28 11:57:28 +08:00
xiaoqidun 1215f7aa08 fix(编辑能力): 修复文本框留白与缩放异常 2026-10-28 11:43:09 +08:00
xiaoqidun 48d1d29b7e fix(编辑能力): 修复编辑选区越框显示
build.yaml / build (push) Successful in 48s
2026-10-28 10:24:24 +08:00
xiaoqidun ba68acbc60 fix(编辑能力): 修复文本框对齐与改宽异常
build.yaml / build (push) Successful in 45s
2026-10-28 10:06:12 +08:00
xiaoqidun a75b90dffc feat(编辑能力): 统一文本框自适应与拖拽调整 2026-10-28 09:44:33 +08:00
xiaoqidun da3f64c74a fix(编辑能力): 修正文本框缩进与组合宽度
build.yaml / build (push) Successful in 47s
2026-10-28 08:19:47 +08:00
xiaoqidun 7e3f2c468a fix(编辑能力): 修正文本框自适应与对齐
build.yaml / build (push) Successful in 45s
2026-10-27 23:45:29 +08:00
xiaoqidun d384527371 fix(编辑能力): 修复对齐时文本框跳动变宽
build.yaml / build (push) Successful in 44s
2026-10-27 23:10:51 +08:00
xiaoqidun 2dcbafb70d fix(编辑能力): 修复文本对齐与转换预览
build.yaml / build (push) Successful in 44s
2026-10-27 22:47:46 +08:00
xiaoqidun f246b0f543 fix(编辑能力): 修复文本对齐与转换偏差
build.yaml / build (push) Successful in 48s
2026-10-27 22:38:16 +08:00
xiaoqidun dfea3d60fd fix(编辑能力): 修复段落对齐、继承与保存问题 2026-10-27 22:19:19 +08:00
xiaoqidun e91e146b50 feat(编辑能力): 补齐分散对齐与段落即时调整 2026-10-27 21:42:05 +08:00
xiaoqidun f2c71c5acf fix(编辑能力): 修复签名依赖与多文档显示
build.yaml / build (push) Successful in 46s
2026-10-27 20:26:23 +08:00
xiaoqidun b59d82a68a feat(编辑能力): 支持签名移除清空及撤销重做 2026-10-27 20:12:49 +08:00
xiaoqidun 44eb9e20b9 fix(修复问题): 修复注解重复等待与进度丢失
build.yaml / build (push) Successful in 46s
2026-10-27 18:25:56 +08:00
xiaoqidun bc71750c78 feat(体验提升): 优化注解加载与侧栏切换 2026-10-27 18:17:42 +08:00
xiaoqidun 143d5e7f92 fix(修复问题): 修复多文档资源与粘贴状态串用
build.yaml / build (push) Successful in 46s
2026-10-27 17:56:48 +08:00
xiaoqidun f76a1ecd1a fix(编辑能力): 修复续搜、撤销及字体隔离
build.yaml / build (push) Successful in 47s
2026-10-27 17:24:29 +08:00
xiaoqidun af8b64e748 feat(阅读编辑): 完善后台刷新、选取与文字编辑
build.yaml / build (push) Successful in 46s
2026-10-27 15:08:25 +08:00
xiaoqidun e67f329776 fix(问题修复): 修复复制隔离与快照释放
build.yaml / build (push) Successful in 47s
2026-10-27 14:35:23 +08:00
xiaoqidun 7bc31b6b84 feat(阅读编辑): 优化进度、框选、搜索与复制
build.yaml / build (push) Successful in 45s
2026-10-27 14:19:36 +08:00
xiaoqidun dfd4f733f9 fix(编辑能力): 修复复制粘贴与嵌套样式
build.yaml / build (push) Successful in 46s
2026-10-27 11:54:18 +08:00
xiaoqidun 88adea348b feat(编辑能力): 完善跨文档复制、样式与选取 2026-10-27 11:25:47 +08:00
xiaoqidun 20374f52f1 fix(编辑能力): 修复组合复制与跨标签编辑问题
build.yaml / build (push) Successful in 1m43s
2026-10-27 10:04:17 +08:00
xiaoqidun 6dae7bdc20 feat(编辑能力): 支持组合内容直接编辑 2026-10-27 09:47:49 +08:00
xiaoqidun 9ee47731c9 fix(编辑能力): 修复单字编辑框多余留白
build.yaml / build (push) Successful in 46s
2026-10-26 22:23:24 +08:00
xiaoqidun 4fee3dfd8e fix(编辑能力): 修复多版本字体裁剪与资源清理
build.yaml / build (push) Successful in 44s
2026-10-26 22:12:22 +08:00
xiaoqidun f322daa616 fix(编辑能力): 修复合字编辑显示异常 2026-10-26 21:47:19 +08:00
43 changed files with 4492 additions and 1111 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# 基础镜像
FROM alpine:3.24.2
FROM alpine:3.24.3
# 作者信息
LABEL authors="肖其顿 (XIAO QI DUN)"
+2 -3
View File
@@ -82,7 +82,6 @@
<span>操作</span>
</div>
<div class="editor-group editor-objects">
<button id="compositeBackButton" class="button" type="button" hidden>返回</button>
<button id="selectObjectButton" class="button" type="button" data-pressed="true">选择</button>
<button id="multiSelectButton" class="button" type="button" data-pressed="false">多选</button>
<button id="editObjectButton" class="button" type="button" disabled>修改</button>
@@ -141,7 +140,7 @@
<option value="left">左齐</option>
<option value="center">居中</option>
<option value="right">右齐</option>
<option value="justify">两端</option>
<option value="justify">两端</option><option value="distributed">分散</option>
</select>
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" title="行距,0为自动" disabled><span>pt</span></span></label>
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
@@ -877,7 +876,7 @@
<section id="signaturePanel" class="signature-panel" hidden>
<div class="panel-row">
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
<span id="signatureSummary" class="count-pill">0</span>
<div class="panel-actions"><span id="signatureSummary" class="count-pill">0</span><button id="signatureClear" class="small-button" type="button" hidden>清空</button></div>
</div>
<div id="signatureList" class="signature-list"></div>
</section>
+11
View File
@@ -3230,6 +3230,10 @@ button.outline-link:hover {
pointer-events: none;
}
.edit-object.edit-parent {
outline: 1px dashed var(--line);
}
.edit-text-source {
visibility: hidden;
}
@@ -3258,6 +3262,12 @@ button.outline-link:hover {
text-indent: inherit;
}
.edit-text div::after,
.edit-text p::after {
content: "";
font-size: 0;
}
.edit-text span[data-glyph] {
position: relative;
-webkit-text-fill-color: transparent;
@@ -3279,6 +3289,7 @@ button.outline-link:hover {
.edit-text span[data-glyph]::after {
content: attr(data-glyph);
letter-spacing: 0;
position: absolute;
left: 0;
top: 0;
+880 -383
View File
File diff suppressed because it is too large. Load diff
+528 -160
View File
File diff suppressed because it is too large. Load diff
+100 -23
View File
@@ -1,17 +1,18 @@
const DATABASE = "ofdgo";
export class FontPicker {
constructor(input, toggle, list, onChange, onOpen) {
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], 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("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); 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(); }
else { input.focus(); input.select(); this.show(); }
});
list.addEventListener("pointerdown", event => {
if (event.target.closest(".font-option")) event.preventDefault();
@@ -23,17 +24,40 @@ export class FontPicker {
window.addEventListener("resize", () => this.close());
}
setFonts(fonts, selected) {
this.close();
setFonts(fonts, selected, preserve = false) {
const open = preserve && this.open;
if (!open) this.close();
this.fonts = fonts;
this.groups = fonts.map((_, index) => [index]);
this.names = new Map();
for (const font of fonts) {
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
this.names.set(name, (this.names.get(name) || 0) + 1);
}
const key = font => font?.id || font?.postscriptName;
const key = font => font?.richKey || font?.id || font?.postscriptName;
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
this.restore();
if (open) {
const active = this.matches[this.active]?.font;
this.show(this.query, false);
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
if (index >= 0) this.activate(index);
} else this.restore();
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
if (this.fonts !== fonts) return;
this.groups = groups;
this.names.clear();
for (const group of groups) {
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
this.names.set(name, (this.names.get(name) || 0) + 1);
}
if (this.open) {
const active = this.matches[this.active]?.index;
this.show(this.query, false);
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
if (index >= 0) this.activate(index);
}
else this.restore();
}).catch(() => {});
}
setDisabled(disabled) {
@@ -43,23 +67,34 @@ export class FontPicker {
restore() {
const font = this.fonts[Number(this.value)];
this.input.value = font?.fullName || font?.name || "";
const value = font?.menuFamily || font?.fullName || font?.name || "";
if (this.input.value !== value) {
this.input.value = value;
if (document.activeElement === this.input) this.input.select();
}
this.input.scrollLeft = 0;
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
}
sourceLabel(font) {
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
if (this.names.get(name) < 2) return "";
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
}
show(query = "", load = true) {
if (this.input.disabled) return;
this.query = query;
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.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
this.matches = this.groups.map(group => {
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
return { font: this.fonts[index], index, group };
}).filter(({ group }) => {
const name = normalize(group.flatMap(index => {
const font = this.fonts[index];
return [font.menuFamily, font.menuStyle, 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();
@@ -70,7 +105,7 @@ export class FontPicker {
option.dataset.index = String(index);
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
const name = document.createElement("span");
name.textContent = font.fullName || font.name;
name.textContent = font.menuFamily || font.fullName || font.name;
option.append(name);
const source = this.sourceLabel(font);
if (source) {
@@ -91,7 +126,7 @@ export class FontPicker {
if (!this.open) this.list.showPopover();
this.open = true;
this.position();
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
if (load) this.onOpen?.();
}
@@ -119,16 +154,27 @@ export class FontPicker {
const changed = this.value !== String(index);
this.value = String(index);
this.close();
if (changed) return this.onChange();
if (changed) {
const task = this.changing = Promise.resolve(this.onChange());
return task.finally(() => { if (this.changing === task) this.changing = null; });
}
}
commitExact() {
const value = this.input.value.trim().toLocaleLowerCase();
const matches = font => font && !font.disabled
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
&& [font.menuFamily, 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);
if (index >= 0) return this.choose(index);
}
prepareCommit(event) {
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
this.commitExact();
this.close();
}
return this.changing;
}
close() {
@@ -137,7 +183,7 @@ export class FontPicker {
}
keyDown(event) {
if (event.isComposing) return;
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
event.stopPropagation();
if (event.key === "Escape") {
@@ -314,13 +360,44 @@ export class FontManager {
}), ...this.catalog];
}
async loadFaces(inspect) {
while (true) {
async loadFaces(inspect, current = () => true) {
while (current()) {
const file = this.userFonts.find(font => font.enabled && !font.faces);
if (!file) return;
if (!file) break;
try { file.faces = await inspect(await this.read(file)); }
catch { file.faces = []; }
catch { if (!current()) return; file.faces = []; }
}
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
while (current() && pending.length) {
const font = pending.shift();
try {
const faces = await inspect(await this.read(font));
const face = faces.find(face => face.postscriptName === font.postscriptName);
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
else font.metadataFailed = true;
} catch { if (current()) font.metadataFailed = true; }
}
}));
}
async defaultFont(order, inspect, current = () => true) {
const fonts = this.editorFonts().filter(font => !font.disabled);
if (!fonts.length) return;
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
const indices = await order(fonts, families);
let fallback = fonts.length;
for (const index of indices) {
if (!current()) return;
try {
await inspect(fonts[index], "中文汉字编辑水印,。!?");
if (current()) return fonts[index];
} catch (error) {
if (error.missingGlyphs) fallback = Math.min(fallback, index);
}
}
if (current()) return fonts[fallback];
}
async add(fonts) {
+4 -5
View File
@@ -417,10 +417,10 @@ export function createPageView(context) {
task.resolve(null);
continue;
}
const scope = state.composite;
if (scope?.index === task.index) {
const objects = await context.callWASM("ofdgoCompositeObjects", task.index, scope.key);
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
if (state.editing) await context.loadEditorMembers(tab, page, task.index);
if (context.activeTab !== tab || state.pageInFlight.get(task.key) !== task || task.openSeq !== state.openSeq) {
task.resolve(null);
continue;
}
await pageView.loadSVGFonts(page.fonts, task.openSeq);
for (const font of page.fonts) delete font.data;
@@ -817,7 +817,6 @@ export function createPageView(context) {
setCurrentPage(index) {
if (index !== context.state.pageIndex && !context.state.openActionTask?.navigating) context.state.openActionTask = null;
if (context.state.composite && context.state.composite.index !== index) context.resetCompositeScope();
context.state.pageIndex = index;
pageView.prunePageRenderQueue();
if (context.state.editing) context.state.openActionPage = context.state.doc?.pages[index]?.id;
+43 -2
View File
@@ -16,6 +16,7 @@ package font
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"strings"
@@ -23,6 +24,8 @@ import (
)
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
type Face struct {
Index int `json:"index"`
FullName string `json:"fullName"`
@@ -30,6 +33,11 @@ type Face struct {
Style string `json:"style"`
PostScriptName string `json:"postscriptName"`
Names []string `json:"names"`
MenuFamily string `json:"menuFamily"`
MenuStyle string `json:"menuStyle"`
FamilyKey string `json:"familyKey"`
Bold bool `json:"bold"`
Italic bool `json:"italic"`
}
// FaceInfo 从名称表读取字体信息
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
}
return name(ids...)
}
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)}
key := ""
if family, version := name(1), name(5); family != "" && version != "" {
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
}
family, style := localized(1), localized(2)
if family == "" {
family = localized(16, 4, 6)
}
if style == "" {
style = localized(17)
}
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
}
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
// 返回: Face 字体信息
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
face := faceInfoLanguage(tables["name"], index, platform, language)
key := face.FamilyKey
if head := tables["head"]; len(head) >= 46 {
key += string(head[4:8])
style := binary.BigEndian.Uint16(head[44:])
face.Bold, face.Italic = style&1 != 0, style&2 != 0
}
if os2 := tables["OS/2"]; len(os2) >= 64 {
key += string(os2[58:62])
style := binary.BigEndian.Uint16(os2[62:])
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
}
if face.FamilyKey != "" {
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
}
return face
}
// FileCount 校验字体或集合头并读取字体数量
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
if err != nil {
return nil, err
}
face := faceInfoLanguage(tables["name"], index, platform, language)
face := TableFaceInfo(tables, index, platform, language)
if face.FullName == "" {
return nil, fmt.Errorf("font %d has no usable name", index)
}
+1 -1
View File
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
values := make(map[[4]uint16]string)
for _, record := range records {
switch record.Name {
case 1, 2, 4, 6, 16, 17:
case 1, 2, 4, 5, 6, 8, 16, 17:
default:
continue
}
+561 -282
View File
File diff suppressed because it is too large. Load diff
+3 -4
View File
@@ -227,8 +227,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
if result != nil && result["ok"] == true && ctx.Err() == nil {
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
clear(worker.pageCache)
clear(worker.textCache)
worker.textOrder = nil
worker.textCache.Clear()
clear(worker.svgFonts)
e.renderIdle = append(e.renderIdle, worker)
}
@@ -380,7 +379,7 @@ func (e *NativeEngine) Close() error {
if e.pendingImport != nil {
err = errors.Join(err, e.pendingImport.Close())
}
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.portableObjects, e.copiedStyle = nil, nil, nil, nil, nil, nil
return err
}
@@ -431,7 +430,7 @@ func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) {
}
return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS,
pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...),
textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), editing: source.editing}, nil
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes), svgFonts: make(map[string][]byte), editing: source.editing}, nil
}
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
+1 -1
View File
@@ -1049,7 +1049,7 @@ func (h *Host) readFonts() {
continue
}
for _, face := range faces {
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file})
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file})
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
+4 -9
View File
@@ -248,7 +248,6 @@ func (e *Editor) PageContext(ctx context.Context, index int) (*PageContent, erro
}
switch object.Type {
case "TextObject":
object.TextObject.layout = nil
layer.TextObject = append(layer.TextObject, object.TextObject)
case "PathObject":
layer.PathObject = append(layer.PathObject, object.PathObject)
@@ -631,7 +630,9 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
}
object, err := cloneEditorObject(layer.Objects[index])
object.origin = e.objectOrigin(id)
e.attachRichTextSnapshot(page, &object)
if err := e.attachRichTextSnapshot(page, &object); err != nil {
return GraphicObject{}, err
}
return object, err
}
@@ -799,13 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
if err != nil {
return err
}
if err := LayoutText(obj, value, options, metrics); err != nil {
return err
}
if obj.layout == nil && e.objectOrigin(obj.ID) != nil {
obj.layout = &textLayout{value: obj.Text(), options: options}
}
return nil
return LayoutText(obj, value, options, metrics)
}
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
+27 -2
View File
@@ -32,6 +32,7 @@ type AnnotationSelection struct {
data [][]byte
resources []string
states map[string]map[string]editorCompositeState
richText map[int]*richTextSnapshot
}
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
@@ -317,7 +318,7 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
if err != nil {
return nil, err
}
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState)}
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState), richText: make(map[int]*richTextSnapshot)}
seen := make(map[string]bool)
for _, id := range ids {
key := editorResourceID(id)
@@ -336,6 +337,17 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
if !editorXMLCopyable(root) {
return nil, fmt.Errorf("annotation contains unsupported identifiers")
}
annotation, err := e.Annotation(page, id)
if err != nil {
return nil, err
}
snapshot, err := e.captureRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}})
if err != nil {
return nil, err
}
if snapshot != nil {
selection.richText[len(selection.data)] = snapshot
}
selection.data = append(selection.data, data)
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
}
@@ -452,7 +464,20 @@ func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx,
break
}
}
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
if err := edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})}); err != nil {
return err
}
}
}
for i, id := range result {
if snapshot := selection.richText[i]; snapshot != nil {
annotation, err := edit.Annotation(page, id)
if err != nil {
return err
}
if err := edit.putRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}}, snapshot); err != nil {
return err
}
}
}
return nil
+8 -2
View File
@@ -96,7 +96,9 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
return nil, err
}
objects[i].origin = e.objectOrigin(editorObjectID(object))
e.attachRichTextSnapshot(page, &objects[i])
if err := e.attachRichTextSnapshot(page, &objects[i]); err != nil {
return nil, err
}
}
return objects, nil
}
@@ -135,7 +137,11 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra
if err := ctx.Err(); err != nil {
return err
}
if err := e.putRichTextMetadata(page, ids[i], meta, -1); err != nil {
object, err := e.Object(page, ids[i])
if err != nil {
return err
}
if err := e.putRichTextSnapshot(page, object, meta); err != nil {
return err
}
}
+3 -1
View File
@@ -334,7 +334,9 @@ func (n *editorCompositeNode) setStates(states map[string]editorCompositeState)
}
}
visit(n.node)
n.object.TextObject.layout = n.states[editorObjectID(n.object)].layout
if state, ok := n.states[editorObjectID(n.object)]; ok {
n.object.TextObject.layout = state.layout
}
}
// setState 替换当前对象的会话记录并计入本次事务
+11 -15
View File
@@ -17,7 +17,6 @@ package ofdgo
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"maps"
"slices"
@@ -37,7 +36,7 @@ type CompositeSelection struct {
containers []int
nodes []*editorCompositeNode
source *editorSourcePage
richText map[int]richTextMetadata
richText map[int]*richTextSnapshot
}
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
@@ -75,7 +74,7 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
slices.Sort(indexes)
var owner *editorCompositeNode
var container *editorXML
selection := &CompositeSelection{richText: make(map[int]richTextMetadata), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
selection := &CompositeSelection{richText: make(map[int]*richTextSnapshot), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
if path.Annotation == "" {
if origin := e.objectOrigin(path.ID); origin != nil {
selection.source = origin.page
@@ -99,11 +98,13 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
copy := *node
copy.owner, copy.span = nil, nil
selection.nodes = append(selection.nodes, &copy)
if entry, err := e.richTextEntry(page, editorObjectID(node.object)); err == nil {
selection.richText[j] = cloneEditorData(entry.metadata)
} else if !errors.Is(err, ErrNotRichText) {
snapshot, err := e.captureRichTextSnapshot(page, node.object)
if err != nil {
return nil, err
}
if snapshot != nil {
selection.richText[j] = snapshot
}
}
scope := root.loadedScope(path.Children)
for scope != owner {
@@ -337,21 +338,16 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
return err
}
object.state = copy.states[editorObjectID(copy.object)]
object.TextObject.layout = object.state.layout
if object.state.layout != nil {
object.TextObject.layout = object.state.layout
}
copy.object.TextObject.layout = object.TextObject.layout
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states
}
object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object}
if meta, ok := selection.richText[i]; ok {
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
if err != nil {
return err
}
meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects)
object.richText = &meta
}
object.richText = selection.richText[i]
objects = append(objects, object)
remaps = append(remaps, copy.remaps...)
}
+3 -1
View File
@@ -251,7 +251,9 @@ func (e *Editor) ungroupCompositeNode(renderer *Renderer, node *editorCompositeN
}
member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
member.object.state = states[editorObjectID(member.object)]
member.object.TextObject.layout = member.object.state.layout
if member.object.state.layout != nil {
member.object.TextObject.layout = member.object.state.layout
}
member.object.CompositeGraphicUnit.states = states
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
objects[i] = member.object
+7 -9
View File
@@ -17,7 +17,6 @@ package ofdgo
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"slices"
@@ -46,7 +45,7 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
return nil, fmt.Errorf("copy requires finite offsets")
}
var result []int
metadata := make(map[int]richTextMetadata)
metadata := make(map[int]*richTextSnapshot)
err := e.Transaction(func(e *Editor) error {
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
container := nodes[0].span.parent
@@ -57,13 +56,12 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
}
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
for i, node := range nodes {
if node.object.Type == "CompositeObject" {
entry, err := e.richTextEntry(page, editorObjectID(node.object))
if err == nil {
metadata[i] = entry.metadata
} else if !errors.Is(err, ErrNotRichText) {
return err
}
snapshot, err := e.captureRichTextSnapshot(page, node.object)
if err != nil {
return err
}
if snapshot != nil {
metadata[i] = snapshot
}
}
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
+17 -5
View File
@@ -20,7 +20,7 @@ import (
"strconv"
)
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按排版选项重排
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
@@ -29,7 +29,7 @@ func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
}
// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位
// StyleCompositeText 批量修改内部文字字体、字号或颜色,已知段落重排,原文保留定位
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
// 返回: error 错误信息
@@ -38,7 +38,7 @@ func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, st
}
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
// 段落选项仅保留在编辑会话中,撤销重做随对象恢复,保存为标准TextCode定位
// 段落选项随标准TextCode定位保存,撤销重做及重新打开时恢复
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
// 返回: error 错误信息
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
@@ -80,6 +80,14 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
object := cloneEditorData(node.object)
text := cloneEditorData(member.Object.TextObject)
state := node.states[editorObjectID(node.object)]
if state.layout == nil && text.layout != nil {
x, _, err := e.richPlainOffset(text)
if err != nil {
return err
}
y, _ := strconv.ParseFloat(text.TextCode[0].Y, 64)
state.layout, state.origin = text.layout, [2]float64{x, y}
}
content, layout := text.TextLayout()
original := content
if style.Font != "" {
@@ -121,7 +129,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
}
object.TextObject.TextCode = text.TextCode
object.TextObject.CGTransform = text.CGTransform
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && state.layout.options.Shape && style.Font != "" && style.Font != object.TextObject.Font {
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && style.Font != "" && style.Font != object.TextObject.Font {
if style.Size != 0 {
text.Size = style.Size
}
@@ -140,7 +148,10 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
}
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
object.TextObject.CGTransform = text.CGTransform
state.layout = &textLayout{value: content, options: layout}
if frame != nil {
text.layout.autoWidth = false
}
state.layout = text.layout
}
if style.Font != "" && style.Font != object.TextObject.Font {
if err := e.prepareText(&text); err != nil {
@@ -161,6 +172,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
}
object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor)
}
object.TextObject.layout = state.layout
if err := node.update(object); err != nil {
return err
}
+15 -3
View File
@@ -963,12 +963,12 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
if root == "" {
root = node.Name.Local
}
if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.Name.Local) {
if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || root == "CustomTags" || slices.Contains([]string{"Extensions", "ExtendData", "Data"}, node.Name.Local) {
uncertain = true
return fmt.Errorf("unknown file dependencies in %s", name)
}
for _, attr := range node.Attr {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.Name, attr) {
uncertain = true
return fmt.Errorf("unknown file dependencies in %s", name)
}
@@ -990,7 +990,19 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
versions[name] = append(versions[name], versionEditLink{node: node, target: target})
}
}
if err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect); err != nil {
err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect)
if uncertain && (root == "CustomTags" || root == "RichText") {
data, readErr := reader.readFileViewContext(ctx, name)
if readErr != nil {
return readErr
}
refs := &editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)}
if _, safe := refs.scanContext(ctx, bytes.NewReader(data), name); safe {
uncertain = false
err = reader.scanDocumentFileReferences(ctx, name, true, add, record, nil)
}
}
if err != nil {
var syntax *xml.SyntaxError
if uncertain || errors.As(err, &syntax) {
return ctx.Err()
+1 -1
View File
@@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins
return nil
}
for _, attr := range node.attrs {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.name, attr) {
scan.unsafe = true
}
}
+4 -1
View File
@@ -93,7 +93,10 @@ func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) {
state := object.state
if object.Type == "TextObject" && object.TextObject.layout != nil {
state.layout = object.TextObject.layout
state.origin[0], _ = strconv.ParseFloat(object.TextObject.TextCode[0].X, 64)
state.origin[0], _, err = e.richPlainOffset(object.TextObject)
if err != nil {
return "", err
}
state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64)
}
states[editorObjectID(object)] = state
+94 -29
View File
@@ -112,63 +112,107 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
// 返回: []string 新对象标识, error 校验、迁移或取消错误
func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) {
objects, _, err := e.importSelection(ctx, source, sourcePage, ids, nil, page, options, true)
return objects, err
}
// ImportSelection 导入独立单页选区包,重映射对象和资源标识,一次撤销恢复全部
// 入参: ctx 取消上下文, source 单页选区阅读器, page 目标页, options 位移及导入配置
// 返回: []string 正文标识, []string 注解标识, error 校验或迁移错误
func (e *Editor) ImportSelection(ctx context.Context, source *Reader, page int, options ObjectImportOptions) ([]string, []string, error) {
doc, err := source.DocContext(ctx)
if err != nil {
return nil, nil, err
}
if len(doc.Pages.Page) != 1 {
return nil, nil, fmt.Errorf("selection requires exactly one page")
}
origin, err := source.EditorContext(ctx)
if err != nil {
return nil, nil, err
}
content, err := origin.page(0)
if err != nil {
return nil, nil, err
}
var objects, annotations []string
for _, layer := range content.Content.Layer {
for _, object := range layer.Objects {
objects = append(objects, editorObjectID(object))
}
}
notes, err := origin.Annotations(0)
if err != nil {
return nil, nil, err
}
for _, note := range notes {
annotations = append(annotations, note.ID)
}
return e.importSelection(ctx, origin, 0, objects, annotations, page, options, true)
}
// importSelection 迁移正文与注解,共用资源映射并保持来源绘制顺序
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, options 导入配置, strictLinks 拒绝跨页链接
// 返回: []string 正文标识, []string 注解标识, error 迁移或提交错误
func (e *Editor) importSelection(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, options ObjectImportOptions, strictLinks bool) ([]string, []string, error) {
progress := editorProgress(options.OnProgress).withContext(ctx)
if err := ctx.Err(); err != nil {
return nil, err
return nil, nil, err
}
if !finite(options.DX) || !finite(options.DY) {
return nil, fmt.Errorf("import requires finite offsets")
return nil, nil, fmt.Errorf("import requires finite offsets")
}
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
return nil, ErrEncryptionPolicyRequired
return nil, nil, ErrEncryptionPolicyRequired
}
objects, err := source.Objects(sourcePage, ids)
if err != nil {
return nil, err
return nil, nil, err
}
target, err := e.page(page)
if err != nil || len(objects) == 0 {
return nil, err
if err != nil || len(objects) == 0 && len(annotations) == 0 {
return nil, nil, err
}
if source == e {
if err := progress.report("commit", 0, 0); err != nil {
return nil, err
return nil, nil, err
}
return e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
copied, err := e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
return copied, nil, err
}
reader, err := source.readerContext(ctx, progress)
if err != nil {
return nil, err
return nil, nil, err
}
defer reader.Close()
doc, err := reader.Doc()
if err != nil {
return nil, err
return nil, nil, err
}
ref := doc.Pages.Page[sourcePage]
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
if err != nil {
return nil, err
return nil, nil, err
}
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, true
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, strictLinks
pageName := reader.ResPath(ref.BaseLoc)
content, err := reader.PageContentByIndex(sourcePage)
if err != nil {
return nil, err
return nil, nil, err
}
for _, value := range content.PageRes {
name, err := migration.resolve(pageName, "", value)
if err != nil {
return nil, err
return nil, nil, err
}
if err := migration.resourceIndex(name); err != nil {
return nil, err
return nil, nil, err
}
}
layers := make([][]byte, len(objects))
for i, object := range objects {
if err := progress.report("objects", i, len(objects)); err != nil {
return nil, err
return nil, nil, err
}
var data []byte
var attrs ofdAttrs
@@ -187,32 +231,48 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
data, err = editorObjectXML(object)
}
if err != nil {
return nil, err
return nil, nil, err
}
data, err = editorXMLContainer("Layer", attrs, data)
if err != nil {
return nil, err
return nil, nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, err
return nil, nil, err
}
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
if err != nil {
return nil, err
return nil, nil, err
}
}
notes := &AnnotationSelection{editor: e, document: e.documentIdentity()}
for _, id := range annotations {
data, err := source.annotationXML(sourcePage, id)
if err != nil {
return nil, nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, nil, err
}
data, err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
if err != nil {
return nil, nil, err
}
notes.data = append(notes.data, data)
}
if err := migration.encodeResources(); err != nil {
return nil, err
return nil, nil, err
}
for _, resource := range e.resources {
delete(migration.files, resource.name)
}
next, err := migration.merge(base, nil, nil, nil)
if err != nil {
return nil, err
return nil, nil, err
}
var copied []string
var copied, copiedNotes []string
err = e.Transaction(func(editor *Editor) error {
if err := progress.report("commit", 0, 0); err != nil {
return err
@@ -236,13 +296,13 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
return err
}
object := layer.Objects[0]
object.TextObject.layout = objects[i].TextObject.layout
object.state = objects[i].state
object.CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
if err != nil {
return err
}
imported[i].TextObject.layout = objects[i].TextObject.layout
imported[i].state = objects[i].state
imported[i].CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
imported[i].origin = &editorObjectOrigin{editor: editor, document: editor.documentIdentity(), data: data, node: root.child("Layer").children[0], object: object}
}
copied, err = editor.CopyObjectsContext(ctx, page, imported, options.DX, options.DY)
@@ -253,12 +313,17 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
for i, object := range objects {
orderedIDs[i] = editorObjectID(object)
}
return editor.copyRichTextMetadata(source, sourcePage, orderedIDs, page, copied)
notes.document = editor.documentIdentity()
copiedNotes, err = editor.PasteAnnotations(page, notes, options.DX, options.DY)
if err != nil {
return err
}
return editor.copyRichTextMetadata(ctx, source, sourcePage, orderedIDs, annotations, page, copied, copiedNotes)
})
if err != nil {
return nil, err
return nil, nil, err
}
return copied, err
return copied, copiedNotes, err
}
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
+5 -5
View File
@@ -76,7 +76,7 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
if err != nil {
return nil, err
}
prefix := editorXMLNamespacePrefix(root)
prefix := editorXMLNamespacePrefix(root, "ofd")
var patches []editorXMLPatch
var visit func(*editorXML, bool) error
visit = func(node *editorXML, declare bool) error {
@@ -152,9 +152,9 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
}
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
// 入参: node 节点
// 入参: node 节点, base 首选前缀
// 返回: string 可用前缀
func editorXMLNamespacePrefix(node *editorXML) string {
func editorXMLNamespacePrefix(node *editorXML, base string) string {
used := make(map[string]bool)
collect := func(current *editorXML) {
for _, attr := range current.attrs {
@@ -174,9 +174,9 @@ func editorXMLNamespacePrefix(node *editorXML) string {
}
}
visit(node)
prefix := "ofd"
prefix := base
for index := 1; used[prefix]; index++ {
prefix = "ofd" + strconv.Itoa(index)
prefix = base + strconv.Itoa(index)
}
return prefix
}
+42 -4
View File
@@ -53,6 +53,7 @@ type editorReferenceScan struct {
stack []string
fonts []*editorFontUsage
text strings.Builder
richText strings.Builder
resource bool
capture bool
safe bool
@@ -140,14 +141,39 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
}
switch token := token.(type) {
case xml.StartElement:
if classifyOFDNamespace(token.Name.Space) == ofdXMLUnknown {
rich := token.Name.Space == richTextNamespace
known := classifyOFDNamespace(token.Name.Space) != ofdXMLUnknown
if rich {
known = len(s.stack) == 0 && token.Name.Local == "RichText" || len(s.stack) == 1 && s.stack[0] == "RichText" && token.Name.Local == "ObjectRef"
} else if len(s.stack) > 0 && s.stack[0] == "RichText" {
known = false
}
if len(s.stack) > 0 && s.stack[0] == "CustomTags" {
known = known && (len(s.stack) == 1 && token.Name.Local == "CustomTag" || len(s.stack) == 2 && s.stack[1] == "CustomTag" && (token.Name.Local == "FileLoc" || token.Name.Local == "SchemaLoc"))
}
if !known {
s.safe = false
return false
}
if token.Name.Local == "CustomTags" || token.Name.Local == "Extensions" || token.Name.Local == "ExtendData" || token.Name.Local == "Data" {
if token.Name.Local == "Extensions" || token.Name.Local == "ExtendData" || token.Name.Local == "Data" {
s.safe = false
return false
}
if token.Name.Local == "CustomTag" {
var kind, namespace string
for _, attr := range token.Attr {
if attr.Name.Space == "" && attr.Name.Local == "TypeID" {
kind = attr.Value
}
if attr.Name.Space == "" && attr.Name.Local == "NameSpace" {
namespace = attr.Value
}
}
if kind != richTextTagType || namespace != richTextNamespace {
s.safe = false
return false
}
}
if len(s.stack) == 0 {
if s.seen {
s.safe = false
@@ -181,10 +207,11 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
}
}
for _, attr := range token.Attr {
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace {
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textMetadataAttribute(token.Name, attr) {
continue
}
if attr.Name.Space != "" {
custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags"
if attr.Name.Space != "" || rich && (token.Name.Local != "ObjectRef" || attr.Name.Local != "PageRef") || custom && (token.Name.Local != "CustomTag" || attr.Name.Local != "TypeID" && attr.Name.Local != "NameSpace") {
s.safe = false
return false
}
@@ -225,6 +252,9 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
s.capture = true
}
case xml.CharData:
if len(s.stack) == 1 && s.stack[0] == "RichText" {
s.richText.Write(token)
}
if len(s.stack) == 0 && len(bytes.Trim(token, " \t\r\n")) != 0 {
s.safe = false
return false
@@ -233,6 +263,14 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
s.text.Write(token)
}
case xml.EndElement:
if len(s.stack) == 3 && s.stack[0] == "CustomTags" && token.Name.Local == "SchemaLoc" && strings.TrimSpace(s.text.String()) != "" {
s.safe = false
return false
}
if len(s.stack) == 1 && s.stack[0] == "RichText" && !richTextMetadataFontSafe(s.richText.String()) {
s.safe = false
return false
}
if len(s.stack) == 0 || s.stack[len(s.stack)-1] != token.Name.Local {
s.safe = false
return false
+456 -123
View File
@@ -25,6 +25,7 @@ import (
"image/color"
"io"
"reflect"
"slices"
"strconv"
)
@@ -45,6 +46,12 @@ type richTextMetadata struct {
Digest string `json:"digest"`
}
// richTextSnapshot 保存对象及嵌套成员的不可变编辑语义,成员按绘制顺序定位
type richTextSnapshot struct {
metadata *richTextMetadata
children map[int]*richTextSnapshot
}
// richTextEntry 保存标引入口及延迟报告的损坏诊断
type richTextEntry struct {
index int
@@ -62,6 +69,7 @@ type richTextIndex struct {
}
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
// 按给定宽度排版,高度随内容自适应
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
// 返回: string 组合对象标识, error 排版、资源或持久化错误
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
@@ -89,12 +97,30 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error)
return id, nil
}
// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换
// UpdateRichText 原子替换富文本内容,保留对象标识、段落宽度及页面变换,高度随内容调整
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
// 入参: page 页面索引, id 对象标识, value 完整富文本
// 返回: error 识别、排版或提交错误
func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
return e.updateRichTextFrame(page, id, value, 0, 0)
return e.updateRichTextFrame(page, id, value, 0, 0, false)
}
// UpdateTargetRichText 一次提交正文或组合内富文本的内容和局部宽度,保留页面变换
// 入参: page 页面索引, path 完整对象路径, value 富文本, offset 局部偏移, width 宽度,零值保留宽度模式
// 返回: error 路径、尺寸或排版错误
func (e *Editor) UpdateTargetRichText(page int, path ObjectPath, value RichText, offset, width float64) error {
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
return fmt.Errorf("invalid text frame")
}
if len(path.Children) == 0 {
if path.Annotation != "" {
return ErrNotRichText
}
return e.updateRichTextFrame(page, path.ID, value, offset, width, false)
}
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
}
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
@@ -108,29 +134,28 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64)
if err != nil {
return err
}
return e.updateRichTextFrame(page, id, value, offset, width)
return e.updateRichTextFrame(page, id, value, offset, width, false)
}
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时沿用原宽度模式
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排原文
// 返回: error 校验或提交错误
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error {
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64, reflow bool) error {
return e.Transaction(func(e *Editor) error {
old, err := e.Object(page, id)
if err != nil {
return err
}
current, err := e.RichText(page, id)
if err != nil {
return err
}
frame, matrix, err := e.RichTextFrame(page, id)
current, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
if err != nil {
return err
}
if err := value.Validate(); err != nil {
return err
}
if width > 0 {
value.AutoWidth = false
}
if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
return nil
}
@@ -255,19 +280,8 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
return Box{}, Matrix{}, err
}
if object.Type == "TextObject" {
if _, err := e.promoteRichText(page, object); err != nil {
return Box{}, Matrix{}, err
}
frame, err := object.TextObject.TextFrame()
if err != nil {
return Box{}, Matrix{}, err
}
box, _ := creationBox(object.TextObject.Boundary)
dx, dy, err := e.richPlainOffset(object.TextObject)
if err != nil {
return Box{}, Matrix{}, err
}
return Box{W: frame.W, H: frame.H}, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(frame.X+dx, frame.Y+dy)), nil
_, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, false)
return frame, matrix, err
}
if object.Type != "CompositeObject" {
return Box{}, Matrix{}, ErrNotRichText
@@ -331,17 +345,34 @@ func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText,
}
// UpdateCompositeRichText 原子重排内部富文本,保留父变换并隔离共享实例
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值保留
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值沿用原宽度模式
// 返回: error 校验或提交错误
func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error {
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
}
// updateCompositeRichText 统一处理内部文字的安全提升与显式重排
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部偏移, width 可选宽度, reflow 是否显式重排
// 返回: error 校验或提交错误
func (e *Editor) updateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
return fmt.Errorf("invalid rich text frame")
}
if width > 0 {
value.AutoWidth = false
}
return e.Transaction(func(e *Editor) error {
member, err := e.CompositeObject(page, path, index)
if err != nil {
return err
}
if member.Object.Type == "TextObject" {
return e.updateCompositePlainRichText(page, path, index, value, offset, width, reflow)
}
var metadata richTextMetadata
entryIndex := -1
changed := false
err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
err = e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
node := nodes[0]
current, frame, _, err := e.CompositeRichText(page, members[0])
if err != nil {
@@ -388,7 +419,7 @@ func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, v
if err != nil || !changed {
return err
}
member, err := e.CompositeObject(page, path, index)
member, err = e.CompositeObject(page, path, index)
if err != nil {
return err
}
@@ -416,12 +447,12 @@ func (e *Editor) putCompositeRichTextMetadata(page int, object GraphicObject, me
// putCompositeRichTextCopies 一次读取复制后的选区,按对象顺序恢复富文本标引
// 入参: page 页面索引, path 父路径, indexes 新成员序号, metadata 选区位置对应的编辑语义
// 返回: error 读取或提交错误
func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]richTextMetadata) error {
func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]*richTextSnapshot) error {
if len(metadata) == 0 {
return nil
}
selected := make([]int, 0, len(metadata))
values := make([]richTextMetadata, 0, len(metadata))
values := make([]*richTextSnapshot, 0, len(metadata))
for i, index := range indexes {
if meta, ok := metadata[i]; ok {
selected = append(selected, index)
@@ -433,14 +464,14 @@ func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes [
return err
}
for i, member := range members {
if err := e.putCompositeRichTextMetadata(page, member.Object, values[i], -1); err != nil {
if err := e.putRichTextSnapshot(page, member.Object, values[i]); err != nil {
return err
}
}
return nil
}
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引,高度随排版内容自适应
// 入参: box 页面或局部编辑框, value 富文本
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
@@ -454,8 +485,9 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
if err != nil {
return GraphicObject{}, richTextMetadata{}, err
}
box.W, box.H = result.width, result.bounds.Y+result.bounds.H
padX, padY := -min(0, result.bounds.X), -min(0, result.bounds.Y)
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: max(box.H, result.bounds.Y+result.bounds.H) + padY}
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: box.H + padY}
if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 {
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
}
@@ -478,7 +510,7 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
if err != nil {
return GraphicObject{}, richTextMetadata{}, err
}
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: max(box.H, result.bounds.Y+result.bounds.H)}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: box.H}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
for i := range meta.Value.Runs {
meta.Value.Runs[i].Format.Font = ""
}
@@ -659,25 +691,31 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
// 入参: page 页面索引, object 普通文字快照
// 返回: RichText 单片段, error 不安全提升错误
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
t := object.TextObject
unsupported := func() (RichText, error) {
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
}
layer, _, err := e.findObject(page, t.ID)
layer, _, err := e.findObject(page, object.TextObject.ID)
if err != nil {
return RichText{}, err
}
if layer.DrawParam != "" || t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
return unsupported()
if origin := e.objectOrigin(object.TextObject.ID); origin != nil && !editorXMLSupported(origin.node) {
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
}
if origin := e.objectOrigin(t.ID); origin != nil && !editorXMLSupported(origin.node) {
return unsupported()
}
frame, err := t.TextFrame()
object, err = e.resolveEditorStyle(object, layer.DrawParam)
if err != nil {
return RichText{}, err
}
return e.promoteTextValue(object.TextObject)
}
// promoteTextValue 校验普通文字是否可无损提升,保留明确段落或可验证单行布局
// 入参: t 有效文字样式与局部定位
// 返回: RichText 单片段, error 布局或格式错误
func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
unsupported := func() (RichText, error) {
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
}
if t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
return unsupported()
}
if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) {
if _, err := t.TextFrame(); err != nil {
return unsupported()
}
value, layout := t.TextLayout()
@@ -693,7 +731,7 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
return unsupported()
}
if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
if !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
return unsupported()
}
for _, dy := range parseFloatsWithG(actual.DeltaY) {
@@ -721,22 +759,20 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
}
color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
}
result := RichText{Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
result := RichText{AutoWidth: t.layout == nil || t.layout.autoWidth, Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
return result, result.Validate()
}
// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局
// richPlainOffset 计算原首行相对标准排版的位移,保留既有段落和组合内基线
// 入参: text 已校验的普通文字
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
if text.layout != nil {
return 0, 0, nil
}
if len(text.TextCode) != 1 {
return 0, 0, fmt.Errorf("positioned text requires explicit layout")
if len(text.TextCode) == 0 {
return 0, 0, fmt.Errorf("text codes are empty")
}
value, layout := text.TextLayout()
check := text
if err := e.LayoutText(&check, text.Text(), TextLayout{}); err != nil {
if err := e.LayoutText(&check, value, layout); err != nil {
return 0, 0, err
}
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
@@ -751,32 +787,60 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
if err != nil {
return 0, 0, err
}
return x, y - baseline, nil
origin, err := strconv.ParseFloat(check.TextCode[0].X, 64)
if err != nil {
return 0, 0, err
}
return x - origin, y - baseline, nil
}
// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用
// 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识
// 返回: error 元数据损坏或提交错误
func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) error {
// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, copied 新正文标识, copiedNotes 新注解标识
// 返回: error 元数据损坏、结构不一致或提交错误
func (e *Editor) copyRichTextMetadata(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, copied, copiedNotes []string) error {
if err := ctx.Err(); err != nil {
return err
}
metadata := source.richTextMetadataIndex()
if metadata.err != nil || len(metadata.entries) == 0 {
return metadata.err
}
copyObject := func(before, after GraphicObject) error {
if err := ctx.Err(); err != nil {
return err
}
snapshot, err := source.captureRichTextSnapshot(sourcePage, before)
if err != nil || snapshot == nil {
return err
}
if err := e.putRichTextSnapshot(page, after, snapshot); err != nil {
return err
}
return ctx.Err()
}
for i, id := range ids {
object, err := source.Object(sourcePage, id)
before, err := source.Object(sourcePage, id)
if err != nil {
return err
}
if object.Type != "CompositeObject" {
continue
}
entry, err := source.richTextEntry(sourcePage, id)
if errors.Is(err, ErrNotRichText) {
continue
}
after, err := e.Object(page, copied[i])
if err != nil {
return err
}
if _, err := source.RichText(sourcePage, id); err != nil {
if err := copyObject(before, after); err != nil {
return err
}
if err := e.putRichTextMetadata(page, copied[i], entry.metadata, -1); err != nil {
}
for i, id := range annotations {
before, err := source.Annotation(sourcePage, id)
if err != nil {
return err
}
after, err := e.Annotation(page, copiedNotes[i])
if err != nil {
return err
}
if err := copyObject(GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: before.Appearance.Objects}}, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: after.Appearance.Objects}}); err != nil {
return err
}
}
@@ -815,25 +879,18 @@ func (e *Editor) richTextColors(object *GraphicObject) error {
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
// 入参: objects 待复制快照
// 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) {
result := make(map[int]richTextMetadata)
// 返回: map[int]*richTextSnapshot 需要复制的语义, error 损坏诊断
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]*richTextSnapshot, error) {
result := make(map[int]*richTextSnapshot)
for i, object := range objects {
if object.Type != "CompositeObject" {
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
continue
}
if object.origin != nil && e.snapshotOrigin(object) == nil {
continue
}
if object.richText != nil {
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
if err != nil {
return nil, err
}
if richTextDigest(unit.object.CompositeGraphicUnit.Objects) != object.richText.Digest {
return nil, fmt.Errorf("rich text snapshot resource has changed")
}
result[i] = cloneEditorData(*object.richText)
result[i] = object.richText
continue
}
index := e.richTextMetadataIndex()
@@ -843,45 +900,125 @@ func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTe
if len(index.entries) == 0 {
continue
}
id := editorObjectID(object)
for page := range e.pages {
entry, err := e.richTextEntry(page, id)
if errors.Is(err, ErrNotRichText) {
continue
}
snapshot, err := e.captureRichTextSnapshot(page, object)
if err != nil {
return nil, err
}
current, err := e.Object(page, id)
if err != nil {
continue
if snapshot != nil {
result[i] = snapshot
break
}
if current.CompositeGraphicUnit.ResourceID != object.CompositeGraphicUnit.ResourceID {
continue
}
if _, err := e.RichText(page, id); err != nil {
return nil, err
}
result[i] = entry.metadata
break
}
}
return result, nil
}
// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在
// attachRichTextSnapshot 为对象快照附加嵌套编辑语义,来源删除后仍可跨页粘贴
// 入参: page 页面索引, object 对象快照
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) {
object.richText = nil
if object.Type != "CompositeObject" {
return
// 返回: error 标引或资源错误
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) error {
snapshot, err := e.captureRichTextSnapshot(page, *object)
object.richText = snapshot
return err
}
// captureRichTextSnapshot 捕获组合树内的富文本标引,不复制共享二进制资源
// 入参: page 页面索引, object 根对象
// 返回: *richTextSnapshot 不可变快照,无标引时为空, error 标引或资源错误
func (e *Editor) captureRichTextSnapshot(page int, object GraphicObject) (*richTextSnapshot, error) {
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
return nil, nil
}
entry, err := e.richTextEntry(page, editorObjectID(*object))
if err != nil {
return
index := e.richTextMetadataIndex()
if index.err != nil || len(index.entries) == 0 {
return nil, index.err
}
meta := cloneEditorData(entry.metadata)
object.richText = &meta
active := make(map[string]bool)
var capture func(GraphicObject) (*richTextSnapshot, error)
capture = func(object GraphicObject) (*richTextSnapshot, error) {
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
return nil, nil
}
entry, err := e.richTextEntry(page, editorObjectID(object))
if err == nil {
if _, err := e.richTextObject(page, object); err != nil {
return nil, err
}
meta := cloneEditorData(entry.metadata)
return &richTextSnapshot{metadata: &meta}, nil
}
if !errors.Is(err, ErrNotRichText) {
return nil, err
}
unit := object.CompositeGraphicUnit
if unit.ResourceID != "" {
if active[unit.ResourceID] {
return nil, fmt.Errorf("cyclic composite resource")
}
active[unit.ResourceID] = true
defer delete(active, unit.ResourceID)
definition, err := e.compositeDefinition(unit.ResourceID)
if err != nil {
return nil, err
}
unit = definition.object.CompositeGraphicUnit
}
var snapshot *richTextSnapshot
i := 0
for child := range unit.objects() {
value, err := capture(child)
if err != nil {
return nil, err
}
if value != nil {
if snapshot == nil {
snapshot = &richTextSnapshot{children: make(map[int]*richTextSnapshot)}
}
snapshot.children[i] = value
}
i++
}
return snapshot, nil
}
return capture(object)
}
// putRichTextSnapshot 恢复复制后的嵌套标引,按目标标准内容更新摘要和字体映射
// 入参: page 目标页, object 复制后的对象, snapshot 来源不可变语义
// 返回: error 结构或标引错误
func (e *Editor) putRichTextSnapshot(page int, object GraphicObject, snapshot *richTextSnapshot) error {
if snapshot.metadata != nil {
index := -1
if entry, err := e.richTextEntry(page, editorObjectID(object)); err == nil {
index = entry.index
} else if !errors.Is(err, ErrNotRichText) {
return err
}
return e.putCompositeRichTextMetadata(page, object, *snapshot.metadata, index)
}
unit := object.CompositeGraphicUnit
if unit.ResourceID != "" {
definition, err := e.compositeDefinition(unit.ResourceID)
if err != nil {
return err
}
unit = definition.object.CompositeGraphicUnit
}
i, count := 0, 0
for child := range unit.objects() {
if value := snapshot.children[i]; value != nil {
if err := e.putRichTextSnapshot(page, child, value); err != nil {
return err
}
count++
}
i++
}
if count != len(snapshot.children) {
return fmt.Errorf("copied composite structure differs")
}
return nil
}
// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
@@ -909,22 +1046,7 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 {
return false, nil
}
var meta richTextMetadata
decoder := json.NewDecoder(bytes.NewBufferString(root.Text))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&meta); err != nil {
return false, nil
}
if decoder.Decode(new(any)) != io.EOF || meta.Version != 1 {
return false, nil
}
for i := range meta.Value.Runs {
if meta.Value.Runs[i].Format.Font != "" {
return false, nil
}
meta.Value.Runs[i].Format.Font = "1"
}
if err := meta.Value.Validate(); err != nil {
if !richTextMetadataFontSafe(root.Text) {
return false, nil
}
for _, child := range root.Children {
@@ -935,3 +1057,214 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
}
return true, ctx.Err()
}
// richTextMetadataFontSafe 核对本库富文本元数据不包含字体资源编号或未知字段
// 入参: value 元数据JSON
// 返回: bool 是否可由标准图元完整统计字体引用
func richTextMetadataFontSafe(value string) bool {
var meta richTextMetadata
decoder := json.NewDecoder(bytes.NewBufferString(value))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&meta); err != nil {
return false
}
if decoder.Decode(new(any)) != io.EOF || meta.Version != 1 {
return false
}
for i := range meta.Value.Runs {
if meta.Value.Runs[i].Format.Font != "" {
return false
}
meta.Value.Runs[i].Format.Font = "1"
}
if err := meta.Value.Validate(); err != nil {
return false
}
return true
}
// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量, reflow 显式重排
// 返回: error 识别、排版或提交错误
func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch, reflow bool) error {
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}, reflow)
if err != nil {
return err
}
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
return err
}
return e.updateCompositeRichText(page, path, index, value, 0, 0, reflow)
}
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排
// 返回: error 校验或提交错误
func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
target := ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}
_, frame, matrix, err := e.PrepareRichText(page, target, reflow)
if err != nil {
return err
}
matrix = matrix.Multiply(TranslationMatrix(offset, 0))
if width > 0 {
frame.W = width
}
var metadata richTextMetadata
if err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
node := nodes[0]
if node.object.Type != "TextObject" || !editorXMLSupported(node.span) {
return ErrNotRichText
}
inverse, ok := node.parent.Invert()
if !ok {
return fmt.Errorf("invalid composite parent transform")
}
next, meta, err := e.prepareRichText(Box{W: frame.W, H: frame.H}, value)
if err != nil {
return err
}
unit := richTextPlacement(meta, inverse.Multiply(matrix))
unit.ID, unit.ResourceID = node.object.TextObject.ID, next.CompositeGraphicUnit.ResourceID
next.CompositeGraphicUnit = unit
if !node.boundaryInCTM {
next = compositeBoundary(next, node.parent, false)
}
metadata = meta
return node.update(next)
}); err != nil {
return err
}
member, err := e.CompositeObject(page, path, index)
if err != nil {
return err
}
return e.putCompositeRichTextMetadata(page, member.Object, metadata, -1)
}
// TargetRichText 读取完整路径文字的富文本模型,普通文字仅在可验证布局时提升
// 入参: page 页面索引, path 完整对象路径
// 返回: RichText 独立模型, error 路径或不安全提升错误
func (e *Editor) TargetRichText(page int, path ObjectPath) (RichText, error) {
value, _, _, err := e.PrepareRichText(page, path, false)
return value, err
}
// PrepareRichText 读取独立富文本模型和局部编辑框,定位文字采用自然宽度,已知段落保留宽度
// 显式重排保留原首行起点及页面变换,不从原始Boundary推断段落宽度
// 入参: page 页面索引, path 完整对象路径, reflow 是否允许重排普通文字
// 返回: RichText 内容, Box 局部编辑框, Matrix 页面变换, error 路径、外观或排版错误
func (e *Editor) PrepareRichText(page int, path ObjectPath, reflow bool) (RichText, Box, Matrix, error) {
var text TextObject
var matrix Matrix
if len(path.Children) == 0 {
if path.Annotation != "" {
return RichText{}, Box{}, Matrix{}, ErrNotRichText
}
object, err := e.Object(page, path.ID)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
if object.Type != "TextObject" {
value, err := e.RichText(page, path.ID)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
frame, matrix, err := e.RichTextFrame(page, path.ID)
return value, frame, matrix, err
}
if origin := e.objectOrigin(path.ID); origin != nil && !editorXMLSupported(origin.node) {
return RichText{}, Box{}, Matrix{}, ErrNotRichText
}
layer, _, err := e.findObject(page, path.ID)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
object, err = e.resolveEditorStyle(object, layer.DrawParam)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
text = object.TextObject
box, err := creationBox(text.Boundary)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
matrix = TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(text.CTM))
} else {
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
member, err := e.CompositeObject(page, path, index)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
if member.Object.Type != "TextObject" {
return e.CompositeRichText(page, member)
}
text, matrix = member.Object.TextObject, member.Matrix
}
frame, err := text.TextFrame()
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
dx, dy, err := e.richPlainOffset(text)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
autoWidth := text.layout == nil || text.layout.autoWidth
if reflow {
value, layout := text.TextLayout()
layout.AutoBidi = true
if err := e.LayoutText(&text, value, layout); err != nil {
return RichText{}, Box{}, Matrix{}, err
}
}
value, err := e.promoteTextValue(text)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
value.AutoWidth = autoWidth
if autoWidth {
result, err := e.layoutRichText(value, frame.W)
if err != nil {
return RichText{}, Box{}, Matrix{}, err
}
frame.W, frame.H = result.width, result.bounds.Y+result.bounds.H
}
return value, Box{W: frame.W, H: frame.H}, matrix.Multiply(TranslationMatrix(dx, dy)), nil
}
// ReflowRichText 原子重排完整路径文字,保留局部宽度、页面变换及共享实例隔离
// 入参: page 页面索引, path 完整对象路径, value 新的富文本内容
// 返回: error 路径、排版或提交错误
func (e *Editor) ReflowRichText(page int, path ObjectPath, value RichText) error {
if len(path.Children) == 0 {
if path.Annotation != "" {
return ErrNotRichText
}
return e.updateRichTextFrame(page, path.ID, value, 0, 0, true)
}
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
return e.updateCompositeRichText(page, path, index, value, 0, 0, true)
}
// LayoutTextFrame 统一调整普通文字及富文本的段落选项和局部宽度,保留原首行起点与页面变换
// 对齐应用于所有段落;定位文字沿用自然宽度,显式改宽后采用固定宽度,提交一条撤销记录
// 入参: page 页面索引, path 完整对象路径, options 段落选项, offset 左边界局部偏移, width 新宽度,零值保留宽度模式
// 返回: error 路径、尺寸或排版错误
func (e *Editor) LayoutTextFrame(page int, path ObjectPath, options TextLayout, offset, width float64) error {
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
return fmt.Errorf("invalid text frame")
}
value, _, _, err := e.PrepareRichText(page, path, true)
if err != nil {
return err
}
value.Layout, value.Paragraphs = options, nil
if len(path.Children) == 0 {
return e.updateRichTextFrame(page, path.ID, value, offset, width, true)
}
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
return e.updateCompositeRichText(page, path, index, value, offset, width, true)
}
+790 -2
View File
@@ -15,18 +15,29 @@
package ofdgo
import (
"cmp"
"context"
"fmt"
"io"
"math"
"slices"
"strings"
)
// ObjectSelection 保存正文与注解的独立快照,共享当前编辑器的不可变资源
// ObjectSelection 保存正文、注解及组合成员的独立快照,共享当前编辑器的不可变资源
// 来源修改、删除或撤销不改变快照,不用于跨文档传输
type ObjectSelection struct {
editor *Editor
document editorDocumentIdentity
objects []GraphicObject
annotations *AnnotationSelection
parts []editorSelectionPart
}
// editorSelectionPart 保存按绘制顺序排列的顶层或内部对象快照
type editorSelectionPart struct {
objects []GraphicObject
composite *CompositeSelection
}
// SelectionBounds 获取正文与注解混合选区的实际范围,复用同一次度量的资源缓存
@@ -400,6 +411,18 @@ func (e *Editor) PasteSelection(page int, selection *ObjectSelection, dx, dy flo
if err == nil && selection.annotations != nil {
annotations, err = edit.PasteAnnotations(page, selection.annotations, dx, dy)
}
for _, part := range selection.parts {
if err != nil {
return err
}
var ids []string
if part.composite != nil {
ids, err = edit.PasteCompositeSelection(page, part.composite, dx, dy)
} else {
ids, err = edit.CopyObjects(page, part.objects, dx, dy)
}
objects = append(objects, ids...)
}
return err
})
if err != nil {
@@ -418,5 +441,770 @@ func (e *Editor) PasteSelectionToComposite(page int, path ObjectPath, selection
if selection.annotations != nil {
return nil, fmt.Errorf("annotations cannot be nested in a page object")
}
return e.CopyObjectsToComposite(page, path, selection.objects, dx, dy)
var indexes []int
err := e.Transaction(func(edit *Editor) error {
var err error
indexes, err = edit.CopyObjectsToComposite(page, path, selection.objects, dx, dy)
for _, part := range selection.parts {
if err != nil {
return err
}
var next []int
if part.composite != nil {
next, err = edit.PasteCompositeObjects(page, path, part.composite, dx, dy)
} else {
next, err = edit.CopyObjectsToComposite(page, path, part.objects, dx, dy)
}
indexes = append(indexes, next...)
}
return err
})
return indexes, err
}
// editorTargetGroup 保存同一父路径的直接成员,结构操作按逆序处理父路径
type editorTargetGroup struct {
path ObjectPath
indexes []int
}
// editorTargetGroups 分离顶层与内部选区,拒绝重复或相互包含的路径
// 入参: paths 完整对象路径
// 返回: []string 正文标识, []string 注解标识, []editorTargetGroup 内部成员, error 路径错误
func editorTargetGroups(paths []ObjectPath) ([]string, []string, []editorTargetGroup, error) {
var objects, annotations []string
var groups []editorTargetGroup
for i, path := range paths {
if (path.ID == "") == (path.Annotation == "") || slices.ContainsFunc(path.Children, func(index int) bool { return index < 0 }) {
return nil, nil, nil, fmt.Errorf("invalid object path")
}
for _, other := range paths[:i] {
length := min(len(path.Children), len(other.Children))
if path.ID == other.ID && path.Annotation == other.Annotation && slices.Equal(path.Children[:length], other.Children[:length]) {
return nil, nil, nil, fmt.Errorf("selection paths overlap")
}
}
if len(path.Children) == 0 {
if path.Annotation != "" {
annotations = append(annotations, path.Annotation)
} else {
objects = append(objects, path.ID)
}
continue
}
index := path.Children[len(path.Children)-1]
path.Children = slices.Clone(path.Children[:len(path.Children)-1])
group := slices.IndexFunc(groups, func(group editorTargetGroup) bool {
return path.ID == group.path.ID && path.Annotation == group.path.Annotation && slices.Equal(path.Children, group.path.Children)
})
if group < 0 {
groups = append(groups, editorTargetGroup{path: path, indexes: []int{index}})
} else {
groups[group].indexes = append(groups[group].indexes, index)
}
}
slices.SortFunc(groups, func(a, b editorTargetGroup) int {
if order := cmp.Compare(a.path.ID, b.path.ID); order != 0 {
return order
}
if order := cmp.Compare(a.path.Annotation, b.path.Annotation); order != 0 {
return order
}
return -slices.Compare(a.path.Children, b.path.Children)
})
return objects, annotations, groups, nil
}
// editTargets 原子处理跨容器选区,结构变化从后向前避免路径位移
// 入参: paths 完整对象路径, root 顶层处理, member 内部处理
// 返回: error 路径或编辑错误
func (e *Editor) editTargets(paths []ObjectPath, root func([]string, []string) error, member func(ObjectPath, []int) error) error {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return err
}
return e.Transaction(func(*Editor) error {
for _, group := range groups {
if err := member(group.path, group.indexes); err != nil {
return err
}
}
if len(objects)+len(annotations) != 0 {
return root(objects, annotations)
}
return nil
})
}
// CaptureTargets 捕获跨容器选区,内部快照保留源变换与裁剪,源删除不影响粘贴
// 入参: page 页面索引, paths 完整对象路径
// 返回: *ObjectSelection 独立快照, error 路径或资源错误
func (e *Editor) CaptureTargets(page int, paths []ObjectPath) (*ObjectSelection, error) {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return nil, err
}
if len(groups) == 0 {
return e.CaptureSelection(page, objects, annotations)
}
selection, err := e.CaptureSelection(page, nil, annotations)
if err != nil {
return nil, err
}
content, err := e.page(page)
if err != nil {
return nil, err
}
positions := e.objectPositions(content)
paths = slices.Clone(paths)
slices.SortStableFunc(paths, func(a, b ObjectPath) int {
if a.ID != b.ID {
if a.ID == "" {
return 1
}
if b.ID == "" {
return -1
}
return compareEditorPosition(positions[a.ID], positions[b.ID])
}
if a.Annotation != b.Annotation {
return cmp.Compare(a.Annotation, b.Annotation)
}
return slices.Compare(a.Children, b.Children)
})
for _, path := range paths {
if len(path.Children) == 0 {
if path.Annotation != "" {
continue
}
items, err := e.Objects(page, []string{path.ID})
if err != nil {
return nil, err
}
selection.parts = append(selection.parts, editorSelectionPart{objects: items})
} else {
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
composite, err := e.CaptureCompositeObjects(page, path, []int{index})
if err != nil {
return nil, err
}
selection.parts = append(selection.parts, editorSelectionPart{composite: composite})
}
}
return selection, nil
}
// TransformTargets 按页面坐标原子变换跨容器选区,保留组合结构
// 入参: page 页面索引, paths 完整对象路径, matrix 有限可逆变换
// 返回: error 路径、变换或编辑错误
func (e *Editor) TransformTargets(page int, paths []ObjectPath, matrix Matrix) error {
if _, ok := matrix.Invert(); !ok {
return fmt.Errorf("selection transform must be finite and invertible")
}
return e.editTargets(paths, func(objects, annotations []string) error {
return e.TransformSelection(page, objects, annotations, matrix)
}, func(path ObjectPath, indexes []int) error {
return e.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return matrix })
})
}
// DeleteTargets 原子删除跨容器选区,一次撤销恢复全部
// 入参: page 页面索引, paths 完整对象路径
// 返回: error 路径或编辑错误
func (e *Editor) DeleteTargets(page int, paths []ObjectPath) error {
return e.editTargets(paths, func(objects, annotations []string) error {
return e.DeleteSelection(page, objects, annotations)
}, func(path ObjectPath, indexes []int) error {
return e.DeleteCompositeObjects(page, path, indexes)
})
}
// StyleTargets 原子设置跨容器选区样式,一次撤销恢复全部
// 入参: page 页面索引, paths 完整对象路径, style 样式增量
// 返回: error 路径或编辑错误
func (e *Editor) StyleTargets(page int, paths []ObjectPath, style ObjectStyle) error {
return e.editTargets(paths, func(objects, annotations []string) error {
return e.StyleSelection(page, objects, annotations, style)
}, func(path ObjectPath, indexes []int) error {
return e.StyleCompositeObjects(page, path, indexes, style)
})
}
// EraseTargets 按页面矩形原子擦除跨容器选区,一次撤销恢复全部
// 入参: page 页面索引, paths 完整对象路径, box 页面毫米范围
// 返回: error 路径或编辑错误
func (e *Editor) EraseTargets(page int, paths []ObjectPath, box Box) error {
return e.editTargets(paths, func(objects, annotations []string) error {
return e.EraseSelection(page, objects, annotations, box)
}, func(path ObjectPath, indexes []int) error {
return e.EraseCompositeObjects(page, path, indexes, box)
})
}
// EraseTargetsPath 按页面多边形原子擦除跨容器选区,一次撤销恢复全部
// 入参: page 页面索引, paths 完整对象路径, points 页面毫米多边形
// 返回: error 路径或编辑错误
func (e *Editor) EraseTargetsPath(page int, paths []ObjectPath, points []Point) error {
return e.editTargets(paths, func(objects, annotations []string) error {
return e.EraseSelectionPath(page, objects, annotations, points)
}, func(path ObjectPath, indexes []int) error {
return e.EraseCompositeObjectsPath(page, path, indexes, points)
})
}
// CopyTargetsStyle 原子复制样式至跨容器选区,保留各对象内容及位置
// 入参: page 页面索引, paths 完整对象路径, source 样式来源
// 返回: error 路径或样式错误
func (e *Editor) CopyTargetsStyle(page int, paths []ObjectPath, source GraphicObject) error {
return e.editTargets(paths, func(objects, annotations []string) error {
if err := e.CopyStyle(page, objects, source); err != nil {
return err
}
return e.editSelectionAnnotations(page, annotations, func(path ObjectPath, indexes []int) error {
return e.CopyCompositeStyle(page, path, indexes, source)
})
}, func(path ObjectPath, indexes []int) error {
return e.CopyCompositeStyle(page, path, indexes, source)
})
}
// targetGeometry 度量跨容器选区,保留输入顺序及独立宽高能力
// 入参: page 页面索引, paths 完整对象路径
// 返回: []Box 对象范围, bool 可独立缩放宽高, Box 页面范围, error 度量错误
func (e *Editor) targetGeometry(page int, paths []ObjectPath) ([]Box, bool, Box, error) {
if _, _, _, err := editorTargetGroups(paths); err != nil {
return nil, false, Box{}, err
}
_, _, paper, err := e.selectionGeometry(page, nil, nil)
if err != nil {
return nil, false, Box{}, err
}
boxes := make([]Box, len(paths))
stretch := true
for i, path := range paths {
if len(path.Children) != 0 {
index := path.Children[len(path.Children)-1]
path.Children = path.Children[:len(path.Children)-1]
member, err := e.CompositeObject(page, path, index)
if err != nil {
return nil, false, Box{}, err
}
boxes[i] = member.Bounds
stretch = stretch && member.Capabilities.Stretch
} else {
var objects, annotations []string
if path.Annotation != "" {
annotations = []string{path.Annotation}
} else {
objects = []string{path.ID}
capability, err := e.ObjectCapabilities(page, path.ID)
if err != nil {
return nil, false, Box{}, err
}
stretch = stretch && capability.Stretch
}
boxes[i], err = e.SelectionBounds(page, objects, annotations)
if err != nil {
return nil, false, Box{}, err
}
}
}
return boxes, stretch, paper, nil
}
// TargetsBounds 获取跨容器选区的共同可见范围
// 入参: page 页面索引, paths 完整对象路径
// 返回: Box 页面毫米范围, error 路径或度量错误
func (e *Editor) TargetsBounds(page int, paths []ObjectPath) (Box, error) {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return Box{}, err
}
if len(groups) == 0 {
return e.SelectionBounds(page, objects, annotations)
}
boxes, _, _, err := e.targetGeometry(page, paths)
if err != nil {
return Box{}, err
}
var bounds Box
for _, box := range boxes {
bounds = unionTextBox(bounds, box)
}
if bounds.W <= 0 || bounds.H <= 0 {
return Box{}, fmt.Errorf("selection has no visible bounds")
}
return bounds, nil
}
// orientTargets 绕跨容器选区中心应用旋转或镜像
// 入参: page 页面索引, paths 完整对象路径, matrix 方向变换
// 返回: error 路径或变换错误
func (e *Editor) orientTargets(page int, paths []ObjectPath, matrix Matrix) error {
bounds, err := e.TargetsBounds(page, paths)
if err != nil {
return err
}
x, y := bounds.X+bounds.W/2, bounds.Y+bounds.H/2
return e.TransformTargets(page, paths, TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y)))
}
// RotateTargets 绕共同中心旋转跨容器选区
// 入参: page 页面索引, paths 完整对象路径, degrees 顺时针直角角度
// 返回: error 路径或变换错误
func (e *Editor) RotateTargets(page int, paths []ObjectPath, degrees int) error {
matrix, err := editorRotation(degrees)
if err != nil {
return err
}
return e.orientTargets(page, paths, matrix)
}
// FlipTargets 绕共同中心镜像跨容器选区
// 入参: page 页面索引, paths 完整对象路径, axis 为horizontal或vertical
// 返回: error 路径或变换错误
func (e *Editor) FlipTargets(page int, paths []ObjectPath, axis string) error {
matrix, err := editorFlip(axis)
if err != nil {
return err
}
return e.orientTargets(page, paths, matrix)
}
// ResizeTargets 按共同范围调整跨容器选区,不重排文字
// 入参: page 页面索引, paths 完整对象路径, box 页面毫米目标范围
// 返回: error 路径、范围或编辑错误
func (e *Editor) ResizeTargets(page int, paths []ObjectPath, box Box) error {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return err
}
if len(groups) == 0 {
return e.ResizeSelection(page, objects, annotations, box)
}
if err := validateCreationBox(box); err != nil {
return err
}
boxes, stretch, _, err := e.targetGeometry(page, paths)
if err != nil {
return err
}
var bounds Box
for _, member := range boxes {
bounds = unionTextBox(bounds, member)
}
if bounds.W <= 0 || bounds.H <= 0 {
return fmt.Errorf("selection has no visible bounds")
}
sx, sy := box.W/bounds.W, box.H/bounds.H
if math.Abs(sx-sy) > 1e-9*math.Max(sx, sy) {
if !stretch {
return fmt.Errorf("nonuniform resizing requires images or image-only groups")
}
} else {
sy = sx
}
return e.TransformTargets(page, paths, Matrix{a: sx, d: sy, e: box.X - bounds.X*sx, f: box.Y - bounds.Y*sy})
}
// DistributionAxes 获取选区可保持位置顺序的等间距分布方向,不修改文档或历史
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径
// 返回: []string 可用的horizontal和vertical方向, error 路径或度量错误
func (e *Editor) DistributionAxes(page int, paths []ObjectPath) ([]string, error) {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return nil, err
}
var boxes []Box
var positions []editorObjectPosition
if len(groups) == 0 {
boxes, positions, _, err = e.selectionGeometry(page, objects, annotations)
} else {
boxes, _, _, err = e.targetGeometry(page, paths)
positions = make([]editorObjectPosition, len(paths))
for i := range positions {
positions[i].index = i
}
}
if err != nil {
return nil, err
}
axes := []string{}
if len(boxes) < 3 {
return axes, nil
}
for _, axis := range []string{"horizontal", "vertical"} {
if _, err := editorDistribution(boxes, positions, axis); err == nil {
axes = append(axes, axis)
}
}
return axes, nil
}
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
// 返回: error 路径、方向或编辑错误
func (e *Editor) ArrangeTargets(page int, paths []ObjectPath, distribute bool, direction string) error {
objects, annotations, groups, err := editorTargetGroups(paths)
if err != nil {
return err
}
if len(groups) == 0 {
if distribute {
return e.DistributeSelection(page, objects, annotations, direction)
}
return e.AlignSelection(page, objects, annotations, direction)
}
if distribute && direction != "horizontal" && direction != "vertical" || !distribute && !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, direction) {
return fmt.Errorf("invalid arrangement direction %q", direction)
}
boxes, _, target, err := e.targetGeometry(page, paths)
if err != nil {
return err
}
matrices := make([]Matrix, len(paths))
if distribute {
positions := make([]editorObjectPosition, len(paths))
for i := range positions {
positions[i].index = i
}
matrices, err = editorDistribution(boxes, positions, direction)
if err != nil {
return err
}
} else {
if len(paths) > 1 {
target = Box{}
for _, box := range boxes {
target = unionTextBox(target, box)
}
}
for i, box := range boxes {
matrices[i] = editorAlignment(box, target, direction)
}
}
return e.Transaction(func(edit *Editor) error {
for i, matrix := range matrices {
if matrix != IdentityMatrix {
if err := edit.TransformTargets(page, paths[i:i+1], matrix); err != nil {
return err
}
}
}
return nil
})
}
// StyleTargetsText 原子修改跨容器文字样式,保留各自定位及组合结构
// 入参: page 页面索引, paths 完整文字路径, style 字体、字号或颜色增量
// 返回: error 路径、文字或样式错误
func (e *Editor) StyleTargetsText(page int, paths []ObjectPath, style TextStyle) error {
patch := TextFormatPatch{}
if style.Font != "" {
patch.Font = &style.Font
}
if style.Size != 0 {
patch.Size = &style.Size
}
if style.Color != "" {
patch.Color = &style.Color
}
return e.formatTargetsText(page, paths, patch, style.Reflow)
}
// FormatTargetsText 原子修改普通文字和富文本的指定格式,保留未指定的混排属性
// 入参: page 页面索引, paths 完整文字路径, patch 格式增量
// 返回: error 路径、格式或不安全重排错误
func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch) error {
return e.formatTargetsText(page, paths, patch, false)
}
// TextFontStyle 标识文字引用的字体资源及显式粗斜体样式
type TextFontStyle struct {
Font string `json:"font"`
Bold bool `json:"bold"`
Italic bool `json:"italic"`
}
// TextFontChange 描述一种原字体款式到目标字体款式的替换
type TextFontChange struct {
From TextFontStyle `json:"from"`
To TextFontStyle `json:"to"`
}
// StyleTargetsFonts 原子替换跨容器文字的各款式,保留混排属性及对象变换
// 目标字体须已注册,可用Transaction将AddFont与本操作合并为一条历史记录
// 入参: page 页面索引, paths 完整文字路径, changes 字体款式映射, reflow 允许显式重排
// 返回: error 字体、路径或排版错误,失败时回滚全部修改
func (e *Editor) StyleTargetsFonts(page int, paths []ObjectPath, changes []TextFontChange, reflow bool) error {
mapping := make(map[TextFontStyle]TextFontStyle, len(changes))
for _, change := range changes {
if change.To.Font == "" {
return fmt.Errorf("text requires a font")
}
mapping[change.From] = change.To
}
return e.Transaction(func(e *Editor) error {
for _, path := range paths {
if path.Annotation != "" {
return ErrNotRichText
}
var object GraphicObject
var err error
if len(path.Children) == 0 {
object, err = e.Object(page, path.ID)
} else {
parent := path
parent.Children = path.Children[:len(path.Children)-1]
var members []CompositeMember
members, err = e.CompositeObjectsAt(page, parent, path.Children[len(path.Children)-1:])
if err == nil {
object = members[0].Object
}
}
if err != nil {
return err
}
if object.Type == "TextObject" && !reflow {
text := object.TextObject
from := TextFontStyle{Font: text.Font, Bold: text.Weight >= 700, Italic: text.Italic}
to, ok := mapping[from]
if !ok || to == from {
continue
}
if to.Bold == from.Bold && to.Italic == from.Italic {
if err := e.StyleTargetsText(page, []ObjectPath{path}, TextStyle{Font: to.Font}); err != nil {
return err
}
continue
}
}
value, _, _, err := e.PrepareRichText(page, path, reflow)
if err != nil {
return err
}
changed := false
for index := range value.Runs {
format := &value.Runs[index].Format
from := TextFontStyle{Font: format.Font, Bold: format.Bold, Italic: format.Italic}
if to, ok := mapping[from]; ok && to != from {
format.Font, format.Bold, format.Italic = to.Font, to.Bold, to.Italic
changed = true
}
}
if !changed {
continue
}
if reflow {
err = e.ReflowRichText(page, path, value)
} else {
err = e.UpdateTargetRichText(page, path, value, 0, 0)
}
if err != nil {
return err
}
}
return nil
})
}
// ReflowTargetsText 显式重排跨容器文字并应用格式增量,保留对象位置及组合结构
// 入参: page 页面索引, paths 完整文字路径, patch 格式增量
// 返回: error 路径、格式或排版错误
func (e *Editor) ReflowTargetsText(page int, paths []ObjectPath, patch TextFormatPatch) error {
return e.formatTargetsText(page, paths, patch, true)
}
// LayoutTargetsText 原子修改跨容器文字的段落增量,保留各自宽度、变换及未指定的样式
// 定位文字显式重排为富文本,任一对象失败时回滚全部修改及新增资源
// 入参: page 页面索引, paths 完整文字路径, patch 段落增量, wrap 折行选项,nil保持原值
// 返回: error 路径、段落或排版错误
func (e *Editor) LayoutTargetsText(page int, paths []ObjectPath, patch RichTextParagraph, wrap *bool) error {
if patch == (RichTextParagraph{}) && wrap == nil {
return nil
}
apply := func(path ObjectPath) error {
value, _, _, err := e.PrepareRichText(page, path, true)
if err != nil {
return err
}
if wrap != nil {
value.Layout.Wrap = *wrap
}
if err := value.FormatParagraphs(0, value.Len(), patch); err != nil {
return err
}
if strings.HasSuffix(value.Text(), "\n") {
if err := value.FormatParagraphs(value.Len(), value.Len(), patch); err != nil {
return err
}
}
return e.ReflowRichText(page, path, value)
}
return e.editTargets(paths, func(ids, annotations []string) error {
if len(annotations) != 0 {
return ErrNotRichText
}
for _, id := range ids {
if err := apply(ObjectPath{ID: id}); err != nil {
return err
}
}
return nil
}, func(path ObjectPath, indexes []int) error {
for _, index := range indexes {
target := path
target.Children = append(slices.Clone(path.Children), index)
if err := apply(target); err != nil {
return err
}
}
return nil
})
}
// formatTargetsText 统一文字选区格式事务,普通字体和颜色修改沿用定位保留路径
// 入参: page 页面索引, paths 完整路径, patch 格式增量, reflow 显式重排
// 返回: error 校验或编辑错误
func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextFormatPatch, reflow bool) error {
style := TextStyle{Reflow: reflow}
if patch.Font != nil {
if strings.TrimSpace(*patch.Font) == "" {
return fmt.Errorf("text requires a font")
}
style.Font = *patch.Font
}
if patch.Size != nil {
if !finite(*patch.Size) || *patch.Size <= 0 {
return fmt.Errorf("invalid text size")
}
style.Size = *patch.Size
}
if patch.Color != nil {
style.Color = *patch.Color
if style.Color == "" {
style.Color = "0 0 0"
}
}
decorated := patch.Bold != nil || patch.Italic != nil || patch.Underline != nil || patch.Strikeout != nil || patch.Shadow != nil || patch.Background != nil
return e.editTargets(paths, func(ids, annotations []string) error {
if len(annotations) != 0 {
return fmt.Errorf("annotation is not a text object")
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
var plain []string
for _, object := range objects {
id := editorObjectID(object)
if object.Type == "TextObject" && !decorated {
plain = append(plain, id)
continue
}
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
if err != nil {
return err
}
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
return err
}
if err := e.updateRichTextFrame(page, id, value, 0, 0, reflow); err != nil {
return err
}
}
if len(plain) != 0 {
return e.StyleText(page, plain, style)
}
return nil
}, func(path ObjectPath, indexes []int) error {
members, err := e.CompositeObjectsAt(page, path, indexes)
if err != nil {
return err
}
var plain []int
for i, member := range members {
if member.Object.Type == "TextObject" && !decorated {
plain = append(plain, indexes[i])
continue
}
if err := e.formatCompositeText(page, path, indexes[i], patch, reflow); err != nil {
return err
}
}
if len(plain) != 0 {
return e.StyleCompositeText(page, path, plain, style)
}
return nil
})
}
// PrepareTargets 将选区及其关联资源复制为独立单页编辑器,来源关闭后仍可使用
// 不修改来源、历史或组合结构,加密来源须显式允许明文导出
// 不包含页面模板和签名,指向其他页面的跳转随单页导出移除
// 入参: ctx 取消上下文, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
// 返回: *Editor 独立选区,可直接导出或继续编辑, error 路径或资源错误
func (e *Editor) PrepareTargets(ctx context.Context, page int, paths []ObjectPath, allowDecrypted bool) (*Editor, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if e.encryption != nil && !allowDecrypted {
return nil, ErrEncryptionPolicyRequired
}
if len(paths) == 0 {
return nil, fmt.Errorf("no objects selected")
}
snapshot := e.transactionSnapshot()
snapshot.history, snapshot.historyIndex, snapshot.historyLimit = nil, 0, 0
snapshot.transactionChanges = nil
for id, origin := range snapshot.origins {
if origin != nil && origin.editor == e {
copy := *origin
copy.editor = &snapshot
snapshot.origins[id] = &copy
}
}
selection, err := snapshot.CaptureTargets(page, paths)
if err != nil {
return nil, err
}
content, err := snapshot.page(page)
if err != nil {
return nil, err
}
objects, annotations, err := snapshot.PasteSelection(page, selection, 0, 0)
if err != nil {
return nil, err
}
box, err := creationBox(content.Area.PhysicalBox)
if err != nil {
return nil, err
}
packet := NewEditor()
packet.SetRenderBackends(e.backends)
packet.SetResources(e.runtimeResources)
packet.SetFontDirs(e.fontDirs...)
packet.SetFontFS(e.fontFS...)
packet.OnWriteProgress = e.OnWriteProgress
if _, err := packet.AddPage(box.W, box.H); err != nil {
return nil, err
}
packet.pages[0].Area = content.Area
if _, _, err := packet.importSelection(ctx, &snapshot, page, objects, annotations, 0, ObjectImportOptions{AllowDecrypted: true, OnProgress: e.OnWriteProgress}, false); err != nil {
return nil, err
}
packet.history, packet.historyIndex = nil, 0
return packet, nil
}
// WriteTargetsTo 将选区及关联资源写成独立单页OFD,不修改来源与历史
// 入参: ctx 取消上下文, writer 输出流, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
// 返回: int64 写入字节数, error 路径、资源或写入错误
func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int, paths []ObjectPath, allowDecrypted bool) (int64, error) {
packet, err := e.PrepareTargets(ctx, page, paths, allowDecrypted)
if err != nil {
return 0, err
}
return packet.WriteToWithOptions(ctx, writer, WriteOptions{})
}
+62 -8
View File
@@ -15,6 +15,9 @@
package ofdgo
import (
"crypto/sha256"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"image/color"
@@ -34,8 +37,8 @@ type MissingGlyphError struct {
// TextLayout 本地横向段落选项
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
@@ -63,10 +66,50 @@ type TextStyle struct {
Reflow bool
}
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染
// textLayout 保存可恢复的原文与排版选项,不参与阅读渲染
type textLayout struct {
value string
options TextLayout
value string
options TextLayout
autoWidth bool
}
// textNamespace 标识普通文字编辑语义的命名空间,版本仅随不兼容的语义变更升级
const textNamespace = "https://ofdgo.aite.me/ns/text/1"
// textMetadataAttribute 判断无资源引用的普通文字编辑元数据属性
// 入参: node 所属节点, attr 属性
// 返回: bool 是否为本库普通文字元数据
func textMetadataAttribute(node xml.Name, attr xml.Attr) bool {
return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textNamespace && attr.Name.Local == "Text"
}
// textMetadata 保存原文、选项与标准定位摘要
type textMetadata struct {
Text string `json:"text"`
Layout TextLayout `json:"layout"`
AutoWidth bool `json:"autoWidth"`
Digest string `json:"digest"`
}
// textDigest 计算与字体编号无关的文字定位摘要
// 返回: string 摘要
func (obj TextObject) textDigest() string {
codes := append([]TextCode(nil), obj.TextCode...)
for i := range codes {
codes[i].Value = string(textCodeRunes(codes[i].Value))
}
data, _ := json.Marshal(codes)
return fmt.Sprintf("%x", sha256.Sum256(data))
}
// textMetadata 编码普通文字的排版语义,与标准定位一起保存
// 返回: string 元数据, error 编码错误
func (obj TextObject) textMetadata() (string, error) {
if obj.layout == nil {
return "", nil
}
data, err := json.Marshal(textMetadata{Text: obj.layout.value, Layout: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.textDigest()})
return string(data), err
}
// Error 返回缺失字符的Unicode码点
@@ -106,7 +149,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error)
return e.UpdateObjects(page, updates)
}
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
// 返回: error 错误信息
@@ -633,9 +676,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
}
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) {
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64, clusters map[int]bool) (float64, string) {
end := len(runes)
for end > 0 && runes[end-1] == ' ' {
end--
@@ -665,6 +708,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st
}
}
}
if alignment == "distributed" && extra > 0 {
var seg segmenter.Segmenter
seg.Init(runes[:end])
for it := seg.GraphemeIterator(); it.Next(); {
cluster := it.Grapheme()
at := cluster.Offset + len(cluster.Text)
if at < end && (clusters == nil || clusters[at]) {
gaps[at-1] = true
}
}
}
deltas := make([]string, max(0, len(runes)-1))
for i := range deltas {
advance := advances[i]
+15 -2
View File
@@ -817,7 +817,7 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
}
return e.putRichTextMetadata(page, first.ID, meta, -1)
}
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
if err := e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
for i, node := range nodes {
if selection.Indexes[i] != first.Index {
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
@@ -838,5 +838,18 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
node.setState(editorCompositeState{})
}
return nil
})
}); err != nil {
return err
}
index := first.Index
for _, part := range preview.analysis.Parts[1:] {
if part.Index < first.Index {
index--
}
}
member, err := e.CompositeObject(page, *selection.Root, index)
if err != nil {
return err
}
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
}
+35 -2
View File
@@ -397,6 +397,9 @@ func editorXMLAttributes(node *editorXML, allowed string) bool {
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
continue
}
if textMetadataAttribute(node.name, attr) {
continue
}
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
return false
}
@@ -569,6 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
return nil, err
}
changes := make(map[string]string)
textValue := func(n *editorXML) string {
for _, attr := range n.attrs {
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
return attr.Value
}
}
return ""
}
textChanged := textValue(oldNode) != textValue(newNode)
if legacyClip {
changes["TransFlag"] = ""
}
@@ -578,7 +590,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
}
}
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
if len(changes) == 0 && !contentChanged && namespace == node.name.Space {
if len(changes) == 0 && !textChanged && !contentChanged && namespace == node.name.Space {
return bytes.Clone(data[node.start:node.end]), nil
}
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
@@ -587,8 +599,29 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
return nil, err
}
start := token.(xml.StartElement)
if textChanged {
value, found := textValue(newNode), false
attrs := start.Attr[:0]
for i, attr := range start.Attr {
resolved := node.attrs[i]
if resolved.Name.Space == textNamespace && resolved.Name.Local == "Text" {
found = true
if value != "" {
attr.Value = value
attrs = append(attrs, attr)
}
continue
}
attrs = append(attrs, attr)
}
start.Attr = attrs
if !found && value != "" {
prefix := editorXMLNamespacePrefix(node, "ofdgo")
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Text"}, Value: value})
}
}
if legacyClip || namespace != node.name.Space {
start.Name.Space = editorXMLNamespacePrefix(node)
start.Name.Space = editorXMLNamespacePrefix(node, "ofd")
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace})
}
var result bytes.Buffer
+98
View File
@@ -17,14 +17,112 @@ package ofdgo
import (
"fmt"
"path"
"sort"
"unicode/utf8"
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
)
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
type FontFace = basefont.Face
// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口
// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源
// 返回: [][]int 按首次出现顺序分组的候选索引
func FontFamilyIndexes(faces []FontFace, sources []string) [][]int {
groups := make([][]int, 0, len(faces))
keys := make([]string, len(faces))
styles := make(map[string]map[[2]bool]bool)
ambiguous := make(map[string]bool)
for index, face := range faces {
key := face.FamilyKey
if len(sources) > index {
key += "\x00" + sources[index]
}
keys[index] = key
if styles[key] == nil {
styles[key] = make(map[[2]bool]bool)
}
style := [2]bool{face.Bold, face.Italic}
ambiguous[key] = ambiguous[key] || styles[key][style]
styles[key][style] = true
}
indexes := make(map[string]int)
for index, face := range faces {
key := keys[index]
if face.FamilyKey == "" || ambiguous[key] {
groups = append(groups, []int{index})
continue
}
group, exists := indexes[key]
if !exists {
group, indexes[key] = len(groups), len(groups)
groups = append(groups, nil)
}
groups[group] = append(groups[group], index)
}
return groups
}
// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式
// 入参: faces 同来源同族候选, bold 粗体, italic 斜体
// 返回: int 候选索引,无可用款式时为-1
func FontStyleIndex(faces []FontFace, bold, italic bool) int {
selected, best := -1, -1
for index, face := range faces {
if face.Bold && !bold || face.Italic && !italic {
continue
}
score := 0
if face.Bold == bold {
score++
}
if face.Italic == italic {
score++
}
if score > best {
selected, best = index, score
}
}
return selected
}
// FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序
// 入参: faces 候选字体, families 按优先级排列的字体族别名组
// 返回: []int 候选字体的零起始索引,不修改输入列表
func FontPreferenceOrder(faces []FontFace, families [][]string) []int {
priorities := make(map[string]int)
for priority, names := range families {
for _, name := range names {
key := FontNormalizeName(name)
if _, exists := priorities[key]; key != "" && !exists {
priorities[key] = priority
}
}
}
order, ranks := make([]int, len(faces)), make([]int, len(faces))
for i, face := range faces {
order[i], ranks[i] = i, len(families)*2
names := append([]string{face.Family, face.FullName, face.PostScriptName}, face.Names...)
for _, name := range names {
if priority, exists := priorities[FontNormalizeName(name)]; exists && priority*2 < ranks[i] {
ranks[i] = priority * 2
}
}
if ranks[i] < len(families)*2 {
switch FontNormalizeName(face.Style) {
case "", "regular", "normal", "roman", "常规", "正常", "标准":
default:
ranks[i]++
}
}
}
sort.SliceStable(order, func(i, j int) bool { return ranks[order[i]] < ranks[order[j]] })
return order
}
// FixFontDataAggressive 激进修复字体数据
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
+41 -1
View File
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
return r.fontData(id, false)
}
// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体
// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识
// 返回: *FontFace 独立字体信息, error 资源或解析错误
func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) {
if r.fontDefinition(id) == nil {
if _, err := r.Fonts(); err != nil {
return nil, err
}
}
font := r.fontDefinition(id)
if font == nil || font.FontFile == "" {
return nil, fmt.Errorf("embedded font %q not found", id)
}
name := r.ResPath(font.FontFile)
key := fmt.Sprintf("%s\x00%d:%d", name, platform, language)
faces, ok := r.fontFaces[key]
if !ok {
data, err := r.readFileView(name)
if err != nil {
return nil, err
}
values, err := (FontFile{Data: data}).LocalizedFaces(platform, language)
if err != nil {
return nil, err
}
faces = make([]*FontFace, len(values))
for index := range values {
faces[index] = &values[index]
}
r.fontFaces[key] = faces
}
names := make([][]string, len(faces))
for index, face := range faces {
names[index] = face.Names
}
face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)]
face.Names = slices.Clone(face.Names)
return &face, nil
}
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
// 返回: []byte 字体数据, error 资源或集合错误
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
faces = make([]*FontFace, count)
for index := range faces {
if tables, err := basefont.FileTables(data, index); err == nil {
info := basefont.FaceInfo(tables["name"], index)
info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff)
faces[index] = &info
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ type GraphicObject struct {
CompositeGraphicUnit CompositeGraphicUnit
origin *editorObjectOrigin
state editorCompositeState
richText *richTextMetadata
richText *richTextSnapshot
}
// Clips 相对于对象外接矩形的裁剪集合
+1 -1
View File
@@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string
return false, false, nil, nil
}
for _, attr := range token.Attr {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(token.Name, attr) {
return false, false, nil, nil
}
}
+50 -5
View File
@@ -31,6 +31,7 @@ import (
// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号
type richLayout struct {
width float64
objects []GraphicObject
bounds Box
anchors []int
@@ -91,8 +92,7 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
return richLayout{}, err
}
options := value.Layout
available := width - options.LeftIndent - options.RightIndent
if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 {
if !finite(width) || width <= 0 {
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
}
geometry, err := e.Geometry()
@@ -124,16 +124,52 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
}
chars := []rune(value.Text())
var face *typefont.Face
if value.AutoWidth && !options.Wrap {
width = 1
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
end := start
for end < len(chars) && chars[end] != '\n' {
end++
}
paragraph := chars[start:end]
local := richParagraphOptions(paragraph, value.ParagraphLayout(paragraphIndex))
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
if err != nil {
return richLayout{}, err
}
}
_, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face)
if err != nil {
return richLayout{}, err
}
length := 0.0
for _, advance := range advances {
length += advance
}
width = max(width, local.LeftIndent+local.RightIndent+max(0, local.FirstLineIndent)+1, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length)
if end == len(chars) {
break
}
start = end + 1
}
}
result.width = width
var underlays []GraphicObject
var shadows []GraphicObject
var y float64
for start := 0; start <= len(chars); {
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
end := start
for end < len(chars) && chars[end] != '\n' {
end++
}
paragraph := chars[start:end]
local := richParagraphOptions(paragraph, options)
local := value.ParagraphLayout(paragraphIndex)
available := width - local.LeftIndent - local.RightIndent
if !finite(available) || available <= 0 || !finite(available-local.FirstLineIndent) || available-local.FirstLineIndent <= 0 {
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
}
local = richParagraphOptions(paragraph, local)
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
if err != nil {
@@ -194,7 +230,16 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height)
}
height = max(height, ascent+descent)
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing)
var clusters map[int]bool
if local.Align == "distributed" {
clusters = make(map[int]bool)
for _, span := range spans {
for _, glyph := range span.glyphs {
clusters[span.start+glyph.Cluster] = true
}
}
}
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing, clusters)
copy(steps, parseFloats(delta))
if local.Shaping.Direction == "rtl" {
for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- {
+189 -6
View File
@@ -17,6 +17,8 @@ package ofdgo
import (
"errors"
"fmt"
"maps"
"slices"
"strings"
"unicode/utf8"
@@ -27,9 +29,43 @@ import (
var ErrNotRichText = errors.New("object is not rich text")
// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节
// Paragraphs为空时所有段落沿用Layout,否则数量必须与LF分隔的段落一致
// AutoWidth在不折行时按各段自然宽度确定文本框,指定宽度调整后关闭
type RichText struct {
Runs []RichTextRun `json:"runs"`
Layout TextLayout `json:"layout"`
AutoWidth bool `json:"autoWidth,omitempty"`
Runs []RichTextRun `json:"runs"`
Layout TextLayout `json:"layout"`
Paragraphs []RichTextParagraph `json:"paragraphs,omitempty"`
}
// RichTextParagraph 保存显式段落选项,空值沿用Layout,按LF分隔的段落顺序排列
type RichTextParagraph struct {
Align string `json:"align"`
LineHeight *float64 `json:"lineHeight,omitempty"`
LetterSpacing *float64 `json:"letterSpacing,omitempty"`
LeftIndent *float64 `json:"leftIndent,omitempty"`
RightIndent *float64 `json:"rightIndent,omitempty"`
FirstLineIndent *float64 `json:"firstLineIndent,omitempty"`
}
// ParagraphLayout 返回指定段落覆盖后的排版选项
// 入参: index 段落序号
// 返回: TextLayout 有效段落选项
func (v RichText) ParagraphLayout(index int) TextLayout {
options := v.Layout
if index < 0 || index >= len(v.Paragraphs) {
return options
}
p := v.Paragraphs[index]
if p.Align != "" {
options.Align = p.Align
}
for _, pair := range []struct{ source, target *float64 }{{p.LineHeight, &options.LineHeight}, {p.LetterSpacing, &options.LetterSpacing}, {p.LeftIndent, &options.LeftIndent}, {p.RightIndent, &options.RightIndent}, {p.FirstLineIndent, &options.FirstLineIndent}} {
if pair.source != nil {
*pair.target = *pair.source
}
}
return options
}
// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米
@@ -64,6 +100,59 @@ type TextFormatPatch struct {
Background *string `json:"background,omitempty"`
}
// RichTextFonts 提取富文本实际引用的独立字体数据,供跨文档保存和复制使用
// 入参: value 富文本内容
// 返回: map[string][]byte 字体标识及数据, error 字体读取错误
func (e *Editor) RichTextFonts(value RichText) (map[string][]byte, error) {
fonts := make(map[string][]byte)
for _, run := range value.Runs {
if run.Text == "" || fonts[run.Format.Font] != nil {
continue
}
data, err := e.FontData(run.Format.Font)
if err != nil {
return nil, err
}
fonts[run.Format.Font] = data
}
return fonts, nil
}
// ImportRichText 注册富文本引用的外来字体并返回独立内容,失败时不保留新增资源
// 未提供数据的字体标识保留为当前文档引用,调用方可在外层事务中同时创建或更新对象
// 入参: value 富文本内容, fonts 外来字体标识及数据
// 返回: RichText 已映射字体标识的内容, error 字体注册错误
func (e *Editor) ImportRichText(value RichText, fonts map[string][]byte) (RichText, error) {
value = cloneEditorData(value)
used := make(map[string]bool)
for _, run := range value.Runs {
used[run.Format.Font] = true
}
err := e.Transaction(func(editor *Editor) error {
registered := make(map[string]string)
for _, key := range slices.Sorted(maps.Keys(fonts)) {
if !used[key] {
continue
}
id, err := editor.AddFont(FontFile{Data: fonts[key]}, 0)
if err != nil {
return err
}
registered[key] = id
}
for i := range value.Runs {
if id := registered[value.Runs[i].Format.Font]; id != "" {
value.Runs[i].Format.Font = id
}
}
return nil
})
if err != nil {
return RichText{}, err
}
return value, nil
}
// Text 返回包含显式换行的原文,不包含排版产生的软换行
// 返回: string 完整原文
func (v RichText) Text() string {
@@ -87,6 +176,14 @@ func (v RichText) Validate() error {
if err := validateRichLayout(v.Layout); err != nil {
return err
}
if len(v.Paragraphs) != 0 && len(v.Paragraphs) != strings.Count(v.Text(), "\n")+1 {
return fmt.Errorf("rich text paragraph count does not match text")
}
for index := range v.Paragraphs {
if err := validateRichLayout(v.ParagraphLayout(index)); err != nil {
return err
}
}
bounds := richTextBoundaries(v.Text())
position := 0
for _, run := range v.Runs {
@@ -134,7 +231,7 @@ func validateRichLayout(v TextLayout) error {
return fmt.Errorf("invalid rich text layout dimensions")
}
switch v.Align {
case "", "left", "center", "right", "justify":
case "", "left", "center", "right", "justify", "distributed":
default:
return fmt.Errorf("invalid rich text alignment")
}
@@ -177,7 +274,13 @@ func (v RichText) Slice(start, end int) (RichText, error) {
if err := v.checkRange(start, end); err != nil {
return RichText{}, err
}
result := RichText{Layout: v.Layout, Runs: v.sliceRuns(start, end)}
result := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(start, end)}
if len(v.Paragraphs) > 0 {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
count := strings.Count(string(chars[start:end]), "\n") + 1
result.Paragraphs = cloneEditorData(v.Paragraphs[first : first+count])
}
if len(result.Runs) == 0 {
position := 0
format := v.Runs[len(v.Runs)-1].Format
@@ -220,13 +323,28 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error {
if err := v.checkRange(start, end); err != nil {
return err
}
next := RichText{Layout: v.Layout, Runs: v.sliceRuns(0, start)}
next := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(0, start)}
next.Runs = append(next.Runs, runs...)
next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...)
if len(next.Runs) == 0 {
caret, _ := v.Slice(start, start)
next.Runs = caret.Runs
}
if len(v.Paragraphs) > 0 {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := first + strings.Count(string(chars[start:end]), "\n")
next.Paragraphs = slices.Clone(v.Paragraphs[:first])
count := 1
for _, run := range runs {
count += strings.Count(run.Text, "\n")
}
for range count {
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[first])
}
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[last+1:]...)
next.Paragraphs = cloneEditorData(next.Paragraphs)
}
next = normalizeRichText(next)
if err := next.Validate(); err != nil {
return err
@@ -255,7 +373,72 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error {
if start == end {
return nil
}
return v.Replace(start, end, part.Runs)
paragraphs := v.Paragraphs
if err := v.Replace(start, end, part.Runs); err != nil {
return err
}
v.Paragraphs = paragraphs
return nil
}
// AlignParagraphs 修改光标所在或半开选区涉及的段落,选区末端不包含下一段首
// 入参: start 起点, end 终点, align 对齐方式
// 返回: error 无效范围或对齐方式
func (v *RichText) AlignParagraphs(start, end int, align string) error {
if err := v.FormatParagraphs(start, end, RichTextParagraph{Align: align}); err != nil {
return err
}
if align == "" {
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := strings.Count(string(chars[:max(start, end-1)]), "\n")
for i := first; i <= last; i++ {
v.Paragraphs[i].Align = ""
}
}
return nil
}
// FormatParagraphs 修改当前或选中段落的显式选项,未提供的字段保持原值
// 入参: start 起点, end 终点, patch 段落增量
// 返回: error 范围或排版错误
func (v *RichText) FormatParagraphs(start, end int, patch RichTextParagraph) error {
if v == nil {
return fmt.Errorf("nil rich text")
}
if err := v.checkRange(start, end); err != nil {
return err
}
if err := validateRichLayout((RichText{Paragraphs: []RichTextParagraph{patch}}).ParagraphLayout(0)); err != nil {
return err
}
chars := []rune(v.Text())
first := strings.Count(string(chars[:start]), "\n")
last := first
if end > start {
last = strings.Count(string(chars[:end-1]), "\n")
}
paragraphs := slices.Clone(v.Paragraphs)
if len(paragraphs) == 0 {
paragraphs = make([]RichTextParagraph, strings.Count(v.Text(), "\n")+1)
}
for i := first; i <= last; i++ {
p := &paragraphs[i]
if patch.Align != "" {
p.Align = patch.Align
}
for _, pair := range []struct {
source *float64
target **float64
}{{patch.LineHeight, &p.LineHeight}, {patch.LetterSpacing, &p.LetterSpacing}, {patch.LeftIndent, &p.LeftIndent}, {patch.RightIndent, &p.RightIndent}, {patch.FirstLineIndent, &p.FirstLineIndent}} {
if pair.source != nil {
value := *pair.source
*pair.target = &value
}
}
}
v.Paragraphs = paragraphs
return nil
}
// apply 应用显式给出的格式字段
+84
View File
@@ -24,8 +24,15 @@ import (
"strings"
"unicode"
"unicode/utf8"
"unsafe"
)
// PageTextCache 按内存预算保留最近访问的页面文字索引
// 缓存后不得修改PageText,页面或字体变化时须删除对应条目
type PageTextCache struct {
cache *renderCache[int, *PageText]
}
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
type textPageTokens struct {
*xml.Decoder
@@ -76,6 +83,83 @@ type TextMatchPart struct {
End int `json:"end"`
}
// NewPageTextCache 创建文字索引缓存,预算包含字符区域和条目开销
// 入参: bytes 内存预算,非正数禁用缓存
// 返回: *PageTextCache 独立缓存
func NewPageTextCache(bytes int) *PageTextCache {
return &PageTextCache{cache: resourceCache[int, *PageText](nil, max(0, bytes))}
}
// MemorySize 估算文字索引占用,包含切片容量和字符串内容
// 返回: int64 估算字节数,共享字符串按各次引用计费
func (p *PageText) MemorySize() int64 {
size := int64(unsafe.Sizeof(*p)) + int64(cap(p.Runs))*int64(unsafe.Sizeof(TextRun{}))
for _, run := range p.Runs {
size += int64(len(run.ID)) + int64(len(run.Text))
size += int64(cap(run.Boxes))*int64(unsafe.Sizeof(Box{})) + int64(cap(run.Spans))*int64(unsafe.Sizeof(TextSpan{}))
}
return size
}
// Get 获取文字索引并更新最近访问顺序
// 入参: index 页面索引
// 返回: *PageText 文字索引,未命中时为nil
func (c *PageTextCache) Get(index int) *PageText {
text, _ := c.cache.get(index)
return text
}
// Put 缓存完整文字索引,超出预算的单页不驱逐其他页面
// 入参: index 页面索引, text 文字索引,nil删除已有条目
func (c *PageTextCache) Put(index int, text *PageText) {
if text == nil {
c.Delete(index)
return
}
size := text.MemorySize() + 128
if size > int64(c.cache.limit) {
c.Delete(index)
return
}
c.cache.put(index, text, int(size))
}
// Delete 删除页面索引并释放预算
// 入参: index 页面索引
func (c *PageTextCache) Delete(index int) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
if entry := c.cache.entries[index]; entry != nil {
c.cache.remove(entry)
}
}
// Clear 清空缓存和计费状态
func (c *PageTextCache) Clear() {
c.cache.clear()
}
// Len 返回缓存页面数
// 返回: int 已缓存页面数
func (c *PageTextCache) Len() int {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
return len(c.cache.entries)
}
// Range 按访问时间从旧到新遍历,遍历期间不得访问当前缓存
// 入参: visit 访问函数,返回false停止遍历
func (c *PageTextCache) Range(visit func(int, *PageText) bool) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
for entry := c.cache.order.Back(); entry != nil; entry = entry.Prev() {
value := entry.Value.(renderCacheEntry[int, *PageText])
if !visit(value.key, value.value) {
return
}
}
}
// Token 读取文字页面所需的XML节点
// 返回: xml.Token XML节点, error 错误信息
func (d *textPageTokens) Token() (xml.Token, error) {
+257
View File
@@ -21,8 +21,10 @@ import (
"crypto"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"maps"
"path"
"slices"
"strconv"
@@ -40,6 +42,261 @@ const (
// SignatureWriteMode 签署方式
type SignatureWriteMode uint8
// ErrSignatureDependency 表示移除操作会改变保留签名或其他版本使用的入口和保护文件
var ErrSignatureDependency = errors.New("signature dependency conflict")
// RemoveSignature 移除当前文档版本的指定签名及外观,支持撤销重做
// 仅清理无其他引用的签名文件,依赖冲突时不修改文档
// 入参: id 签名标识
// 返回: error 标识、依赖或读取错误
func (e *Editor) RemoveSignature(id string) error {
return e.RemoveSignatureContext(context.Background(), id)
}
// RemoveSignatureContext 移除指定签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文, id 签名标识
// 返回: error 标识、依赖、读取或取消错误
func (e *Editor) RemoveSignatureContext(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("empty signature ID")
}
return e.removeSignatures(ctx, id)
}
// ClearSignatures 清空当前文档版本的签名及外观,保留其他文档和版本的引用
// 无签名时不产生历史记录,依赖冲突时不修改文档
// 返回: error 依赖或读取错误
func (e *Editor) ClearSignatures() error {
return e.ClearSignaturesContext(context.Background())
}
// ClearSignaturesContext 清空当前签名,取消或失败时不改变文档和历史
// 入参: ctx 取消上下文
// 返回: error 依赖、读取或取消错误
func (e *Editor) ClearSignaturesContext(ctx context.Context) error {
return e.removeSignatures(ctx, "")
}
// removeSignatures 在独立快照中移除签名入口,校验保留依赖后提交历史
// 入参: ctx 取消上下文, id 签名标识,空值清空
// 返回: error 标识、依赖、结构或取消错误
func (e *Editor) removeSignatures(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
reader, err := e.readerContext(ctx, nil)
if err != nil {
return err
}
doc, err := reader.DocContext(ctx)
if err != nil {
return err
}
if doc.Signatures == "" {
if id != "" {
return fmt.Errorf("signature not found: %s", id)
}
return nil
}
name := reader.ResPath(doc.Signatures)
data, err := reader.readFileViewContext(ctx, name)
if err != nil {
return err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return err
}
removed := make(map[string]bool)
candidates := make(map[string]bool)
var patches []editorXMLPatch
remaining := 0
for _, node := range root.children {
if !node.matchesOFD("Signature") {
continue
}
if id != "" && node.attr("ID") != id {
remaining++
continue
}
location := signatureRefPath(name, node.attr("BaseLoc"))
files, _, err := reader.signatureRemovalFiles(ctx, location)
if err != nil {
return err
}
maps.Copy(candidates, files)
removed[node.attr("ID")] = true
patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
}
if id != "" && !removed[id] {
return fmt.Errorf("signature not found: %s", id)
}
protected, dependencies, err := reader.signatureRemovalProtected(ctx, name, removed)
if err != nil {
return err
}
parts := make(map[string][]byte)
if reader.versionInfo != nil {
if remaining != 0 {
return fmt.Errorf("%w: version shares signature list %s", ErrSignatureDependency, name)
}
candidates[name] = true
used, err := reader.versionFileDependencies(ctx, reader.documentRoot, nil, nil)
if err != nil {
return err
}
removedFiles := maps.Clone(candidates)
for file := range used {
delete(removedFiles, file)
}
shared, err := reader.versionSharedFiles(ctx)
if err != nil {
return err
}
if err := reader.versionManifest(ctx, parts, nil, used, shared, removedFiles); err != nil {
return err
}
} else {
if dependencies[name] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name)
}
for file := range candidates {
if dependencies[file] {
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, file)
}
}
versions, err := reader.DocumentVersionsContext(ctx)
if err != nil {
return err
}
for _, version := range versions {
for _, file := range version.Files {
if file.Location == name {
return fmt.Errorf("%w: version %s uses %s", ErrSignatureDependency, version.ID, name)
}
}
}
entry, err := reader.readFileViewContext(ctx, "OFD.xml")
if err != nil {
return err
}
ofd, err := parseEditorXMLContext(ctx, entry)
if err != nil {
return err
}
node := ofd.childAt("DocBody", reader.documentIndex).child("Signatures")
if remaining == 0 {
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{{start: node.start, end: node.end}})
candidates[name] = true
} else {
target := name
shared := false
for index, body := range reader.OFD.DocBody {
shared = shared || index != reader.documentIndex && body.Signatures != "" && signatureRefPath("OFD.xml", body.Signatures) == name
}
if shared {
target = packageAvailableName(reader, parts, name)
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{editorXMLContent(entry, node, editorFontXMLText("/"+target))})
}
parts[target] = editorPatchXML(data, patches)
}
}
for file, updated := range parts {
if !protected[file] {
continue
}
same, err := packageDataEqual(ctx, reader, file, updated)
if err != nil {
return err
}
if !same {
return fmt.Errorf("%w: protected file %s", ErrSignatureDependency, file)
}
}
for file, data := range reader.files {
if _, changed := parts[file]; !changed {
parts[file] = data
}
}
reader.files = parts
for file := range protected {
delete(candidates, file)
}
if err := reader.retireVersionFiles(ctx, candidates); err != nil {
return err
}
return e.commitResourceSnapshot(ctx, reader)
}
// signatureRemovalFiles 区分签名自有文件和摘要保护内容,正文不作为删除候选
// 入参: ctx 取消上下文, name 签名描述路径
// 返回: map[string]bool 自有文件, map[string]bool 保护文件, error 读取或解析错误
func (r *Reader) signatureRemovalFiles(ctx context.Context, name string) (map[string]bool, map[string]bool, error) {
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
var signature SignatureFile
if err := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}).Decode(&signature); err != nil {
return nil, nil, err
}
owned := map[string]bool{name: true}
for _, location := range []string{signature.SignedValue, signature.SignedInfo.Seal.BaseLoc} {
if location != "" {
owned[signatureRefPath(name, location)] = true
}
}
protected := make(map[string]bool)
for _, reference := range signature.SignedInfo.References.Reference {
protected[signatureRefPath(name, reference.FileRef)] = true
}
return owned, protected, ctx.Err()
}
// signatureRemovalProtected 汇总保留签名的原文及其他文档的签名入口,不要求证书或私钥
// 入参: ctx 取消上下文, selected 当前签名列表, removed 待移除标识
// 返回: map[string]bool 保留路径, map[string]bool 非共享的摘要保护路径, error 读取、结构或取消错误
func (r *Reader) signatureRemovalProtected(ctx context.Context, selected string, removed map[string]bool) (map[string]bool, map[string]bool, error) {
protected := make(map[string]bool)
dependencies := make(map[string]bool)
retained := make(map[string]bool)
for index, body := range r.OFD.DocBody {
if body.Signatures == "" {
continue
}
name := signatureRefPath("OFD.xml", body.Signatures)
current := index == r.documentIndex && r.versionInfo == nil
if !current {
protected[name] = true
}
data, err := r.readFileViewContext(ctx, name)
if err != nil {
return nil, nil, err
}
root, err := parseEditorXMLContext(ctx, data)
if err != nil {
return nil, nil, err
}
for _, node := range root.children {
if !node.matchesOFD("Signature") || current && name == selected && removed[node.attr("ID")] {
continue
}
owned, refs, err := r.signatureRemovalFiles(ctx, signatureRefPath(name, node.attr("BaseLoc")))
if err != nil {
return nil, nil, err
}
maps.Copy(protected, owned)
maps.Copy(retained, owned)
maps.Copy(protected, refs)
maps.Copy(dependencies, refs)
}
}
for name := range retained {
delete(dependencies, name)
}
return protected, dependencies, ctx.Err()
}
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容
+9
View File
@@ -16,6 +16,7 @@ package ofdgo
import (
"context"
"encoding/json"
"encoding/xml"
"math"
"strconv"
@@ -430,6 +431,14 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement
return err
}
*obj = TextObject(value)
for _, attr := range start.Attr {
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
var meta textMetadata
if json.Unmarshal([]byte(attr.Value), &meta) == nil && validateRichLayout(meta.Layout) == nil && structureXMLText(meta.Text) && meta.Digest == obj.textDigest() {
obj.layout = &textLayout{value: meta.Text, options: meta.Layout, autoWidth: meta.AutoWidth}
}
}
}
for _, attr := range ofdXMLStart(start).Attr {
if attr.Name.Local == "LineWidth" {
obj.LineWidthSet = obj.LineWidth == 0
+15 -7
View File
@@ -33,7 +33,7 @@ type configuredTextShaper struct {
}
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
// 不修改文档,通过AddObject或UpdateObject提交;选项随标准文字定位保存,重新打开时恢复
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
// 返回: error 错误信息
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
@@ -63,7 +63,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) {
return fmt.Errorf("paragraph indents must be finite")
}
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
if !slices.Contains([]string{"", "left", "center", "right", "justify", "distributed"}, options.Align) {
return fmt.Errorf("invalid text alignment %q", options.Align)
}
var width float64
@@ -129,7 +129,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if i == 0 {
indent = options.FirstLineIndent
}
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing)
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing, nil)
x += options.LeftIndent + indent
y := float64(ascender)*unit + float64(len(codes))*lineHeight
if !finite(x) || !finite(y) {
@@ -143,10 +143,11 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
}
obj.TextCode = codes
obj.CGTransform = nil
obj.layout = nil
if options != (TextLayout{}) {
obj.layout = &textLayout{value: value, options: options}
autoWidth := options == (TextLayout{})
if obj.layout != nil && obj.layout.options == options {
autoWidth = obj.layout.autoWidth
}
obj.layout = &textLayout{value: value, options: options, autoWidth: autoWidth}
return nil
}
@@ -220,7 +221,14 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
}
}
chars := runes[line[0]:line[1]]
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
var clusters map[int]bool
if options.Align == "distributed" {
clusters = make(map[int]bool)
for _, glyph := range glyphs {
clusters[glyph.Cluster] = true
}
}
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing, clusters)
if options.Shaping.Direction == "rtl" {
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
x -= lineAdvances[j]
+9
View File
@@ -1242,6 +1242,15 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
switch object.Type {
case "TextObject", "Text":
obj := object.TextObject
metadata, err := obj.textMetadata()
if err != nil {
x.err = err
return
}
if metadata != "" {
attrs.add("xmlns:ofdgo", textNamespace)
attrs.add("ofdgo:Text", metadata)
}
attrs.add("ID", obj.ID)
attrs.add("DrawParam", obj.DrawParam)
attrs.add("Boundary", obj.Boundary)
+4 -1
View File
@@ -93,7 +93,10 @@ func decodeOFDChildren(d *xml.Decoder, start xml.StartElement, decode func(*xml.
}
continue
}
if err := decode(d, ofdXMLStart(node)); err != nil {
if node.Name.Local != "TextObject" && node.Name.Local != "Text" {
node = ofdXMLStart(node)
}
if err := decode(d, node); err != nil {
return err
}
case xml.EndElement: