mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
Compare commits
63
Commits
51ab8846e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385d7aa531 | ||
|
|
94157b10c3 | ||
|
|
aebcda51a3 | ||
|
|
19084bb40b | ||
|
|
545b697310 | ||
|
|
1a25472b43 | ||
|
|
af80fcc9ba | ||
|
|
838a769c5b | ||
|
|
5b4f2472f7 | ||
|
|
fb51fd3b07 | ||
|
|
80e548f93e | ||
|
|
9c3a04b7eb | ||
|
|
9bb6c9141a | ||
|
|
4320b53d4a | ||
|
|
e5d7f577c7 | ||
|
|
7a23dcc6dd | ||
|
|
12aaada982 | ||
|
|
62631472ea | ||
|
|
9d6bf3e87f | ||
|
|
f8c8259502 | ||
|
|
f4429b126a | ||
|
|
50898934dc | ||
|
|
c2b0339fdf | ||
|
|
e5658de69d | ||
|
|
057bcba868 | ||
|
|
89a7ef04c7 | ||
|
|
e35533859c | ||
|
|
1215f7aa08 | ||
|
|
48d1d29b7e | ||
|
|
ba68acbc60 | ||
|
|
a75b90dffc | ||
|
|
da3f64c74a | ||
|
|
7e3f2c468a | ||
|
|
d384527371 | ||
|
|
2dcbafb70d | ||
|
|
f246b0f543 | ||
|
|
dfea3d60fd | ||
|
|
e91e146b50 | ||
|
|
f2c71c5acf | ||
|
|
b59d82a68a | ||
|
|
44eb9e20b9 | ||
|
|
bc71750c78 | ||
|
|
143d5e7f92 | ||
|
|
f76a1ecd1a | ||
|
|
af8b64e748 | ||
|
|
e67f329776 | ||
|
|
7bc31b6b84 | ||
|
|
dfd4f733f9 | ||
|
|
88adea348b | ||
|
|
20374f52f1 | ||
|
|
6dae7bdc20 | ||
|
|
9ee47731c9 | ||
|
|
4fee3dfd8e | ||
|
|
f322daa616 | ||
|
|
04efce42d8 | ||
|
|
41a50d9e96 | ||
|
|
f1864952ad | ||
|
|
413d41c31d | ||
|
|
fb4f03465b | ||
|
|
04d7d2d672 | ||
|
|
15a532a1ca | ||
|
|
1061b07942 | ||
|
|
01e29618f4 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 基础镜像
|
# 基础镜像
|
||||||
FROM alpine:3.24.2
|
FROM alpine:3.24.3
|
||||||
|
|
||||||
# 作者信息
|
# 作者信息
|
||||||
LABEL authors="肖其顿 (XIAO QI DUN)"
|
LABEL authors="肖其顿 (XIAO QI DUN)"
|
||||||
|
|||||||
@@ -82,7 +82,6 @@
|
|||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group editor-objects">
|
<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="selectObjectButton" class="button" type="button" data-pressed="true">选择</button>
|
||||||
<button id="multiSelectButton" class="button" type="button" data-pressed="false">多选</button>
|
<button id="multiSelectButton" class="button" type="button" data-pressed="false">多选</button>
|
||||||
<button id="editObjectButton" class="button" type="button" disabled>修改</button>
|
<button id="editObjectButton" class="button" type="button" disabled>修改</button>
|
||||||
@@ -141,7 +140,7 @@
|
|||||||
<option value="left">左齐</option>
|
<option value="left">左齐</option>
|
||||||
<option value="center">居中</option>
|
<option value="center">居中</option>
|
||||||
<option value="right">右齐</option>
|
<option value="right">右齐</option>
|
||||||
<option value="justify">两端</option>
|
<option value="justify">两端</option><option value="distributed">分散</option>
|
||||||
</select>
|
</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>
|
<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>
|
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
|
||||||
@@ -877,7 +876,7 @@
|
|||||||
<section id="signaturePanel" class="signature-panel" hidden>
|
<section id="signaturePanel" class="signature-panel" hidden>
|
||||||
<div class="panel-row">
|
<div class="panel-row">
|
||||||
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
|
<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>
|
||||||
<div id="signatureList" class="signature-list"></div>
|
<div id="signatureList" class="signature-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3230,6 +3230,10 @@ button.outline-link:hover {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-object.edit-parent {
|
||||||
|
outline: 1px dashed var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
.edit-text-source {
|
.edit-text-source {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -3258,6 +3262,12 @@ button.outline-link:hover {
|
|||||||
text-indent: inherit;
|
text-indent: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-text div::after,
|
||||||
|
.edit-text p::after {
|
||||||
|
content: "";
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-text span[data-glyph] {
|
.edit-text span[data-glyph] {
|
||||||
position: relative;
|
position: relative;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
@@ -3279,6 +3289,7 @@ button.outline-link:hover {
|
|||||||
|
|
||||||
.edit-text span[data-glyph]::after {
|
.edit-text span[data-glyph]::after {
|
||||||
content: attr(data-glyph);
|
content: attr(data-glyph);
|
||||||
|
letter-spacing: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
+1112
-442
File diff suppressed because it is too large.
Load diff
+550
-165
File diff suppressed because it is too large.
Load diff
+100
-23
@@ -1,17 +1,18 @@
|
|||||||
const DATABASE = "ofdgo";
|
const DATABASE = "ofdgo";
|
||||||
|
|
||||||
export class FontPicker {
|
export class FontPicker {
|
||||||
constructor(input, toggle, list, onChange, onOpen) {
|
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
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("input", () => this.show(input.value));
|
||||||
input.addEventListener("focus", () => input.select());
|
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("keydown", event => this.keyDown(event));
|
||||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
if (this.open) this.close();
|
if (this.open) this.close();
|
||||||
else { input.focus(); this.show(); }
|
else { input.focus(); input.select(); this.show(); }
|
||||||
});
|
});
|
||||||
list.addEventListener("pointerdown", event => {
|
list.addEventListener("pointerdown", event => {
|
||||||
if (event.target.closest(".font-option")) event.preventDefault();
|
if (event.target.closest(".font-option")) event.preventDefault();
|
||||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
|||||||
window.addEventListener("resize", () => this.close());
|
window.addEventListener("resize", () => this.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
setFonts(fonts, selected) {
|
setFonts(fonts, selected, preserve = false) {
|
||||||
this.close();
|
const open = preserve && this.open;
|
||||||
|
if (!open) this.close();
|
||||||
this.fonts = fonts;
|
this.fonts = fonts;
|
||||||
|
this.groups = fonts.map((_, index) => [index]);
|
||||||
this.names = new Map();
|
this.names = new Map();
|
||||||
for (const font of fonts) {
|
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);
|
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.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) {
|
setDisabled(disabled) {
|
||||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
|||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
const font = this.fonts[Number(this.value)];
|
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.scrollLeft = 0;
|
||||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceLabel(font) {
|
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 "";
|
if (this.names.get(name) < 2) return "";
|
||||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||||
}
|
}
|
||||||
|
|
||||||
show(query = "", load = true) {
|
show(query = "", load = true) {
|
||||||
if (this.input.disabled) return;
|
if (this.input.disabled) return;
|
||||||
|
this.query = query;
|
||||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||||
const terms = normalize(query).split(" ").filter(Boolean);
|
const terms = normalize(query).split(" ").filter(Boolean);
|
||||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
this.matches = this.groups.map(group => {
|
||||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
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));
|
return terms.every(term => name.includes(term));
|
||||||
});
|
});
|
||||||
this.list.replaceChildren();
|
this.list.replaceChildren();
|
||||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
|||||||
option.dataset.index = String(index);
|
option.dataset.index = String(index);
|
||||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.textContent = font.fullName || font.name;
|
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||||
option.append(name);
|
option.append(name);
|
||||||
const source = this.sourceLabel(font);
|
const source = this.sourceLabel(font);
|
||||||
if (source) {
|
if (source) {
|
||||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
|||||||
if (!this.open) this.list.showPopover();
|
if (!this.open) this.list.showPopover();
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.position();
|
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);
|
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||||
if (load) this.onOpen?.();
|
if (load) this.onOpen?.();
|
||||||
}
|
}
|
||||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
|||||||
const changed = this.value !== String(index);
|
const changed = this.value !== String(index);
|
||||||
this.value = String(index);
|
this.value = String(index);
|
||||||
this.close();
|
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() {
|
commitExact() {
|
||||||
const value = this.input.value.trim().toLocaleLowerCase();
|
const value = this.input.value.trim().toLocaleLowerCase();
|
||||||
const matches = font => font && !font.disabled
|
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 selected = Number(this.value);
|
||||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
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() {
|
close() {
|
||||||
@@ -137,7 +183,7 @@ export class FontPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyDown(event) {
|
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;
|
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -314,13 +360,44 @@ export class FontManager {
|
|||||||
}), ...this.catalog];
|
}), ...this.catalog];
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFaces(inspect) {
|
async loadFaces(inspect, current = () => true) {
|
||||||
while (true) {
|
while (current()) {
|
||||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
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)); }
|
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) {
|
async add(fonts) {
|
||||||
|
|||||||
+93
-25
@@ -35,7 +35,7 @@ class BrowserScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
priority(task) {
|
priority(task) {
|
||||||
return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload);
|
return Number(task.worker.batch || this.owner(task.worker, task.data)?.foreground === false) + Number(!!task.data.preload || !!task.data.background);
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(worker, data, transfer) {
|
submit(worker, data, transfer) {
|
||||||
@@ -64,10 +64,16 @@ class BrowserScheduler {
|
|||||||
if (data.id == null || data.type) return;
|
if (data.id == null || data.type) return;
|
||||||
const key = requestKey(worker, data);
|
const key = requestKey(worker, data);
|
||||||
const started = worker.requests.get(key);
|
const started = worker.requests.get(key);
|
||||||
|
const elapsed = data.elapsed ?? (started === undefined ? 0 : performance.now() - started);
|
||||||
|
const operation = worker.operations.get(key), owner = this.owner(worker, data);
|
||||||
|
if (worker.primary && started !== undefined && data.ok && owner) {
|
||||||
|
if (operation?.name === "ofdgoOpen") owner.openTime = elapsed;
|
||||||
|
if (operation?.name === "ofdgoRenderPage") owner.pageTime = owner.pageTime ? owner.pageTime * 0.75 + elapsed * 0.25 : elapsed;
|
||||||
|
}
|
||||||
worker.requests.delete(key);
|
worker.requests.delete(key);
|
||||||
worker.operations.delete(key);
|
worker.operations.delete(key);
|
||||||
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
|
if (this.memory > this.budget) this.limit = Math.max(1, this.limit - 1);
|
||||||
else if (started !== undefined && data.ok && performance.now() - started > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1);
|
else if (started !== undefined && data.ok && elapsed > 100 && this.memory + Math.max(128 << 20, worker.memory) < this.budget) this.limit = Math.min(this.capacity, this.limit + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
drain() {
|
drain() {
|
||||||
@@ -86,8 +92,8 @@ class BrowserScheduler {
|
|||||||
const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot");
|
const busy = [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name !== "ofdgoSaveSnapshot");
|
||||||
const snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot");
|
const snapshot = task.data.name === "ofdgoSaveSnapshot" && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID && data.name === "ofdgoSaveSnapshot");
|
||||||
const active = this.active();
|
const active = this.active();
|
||||||
const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload;
|
const interactive = task.worker.primary && this.owner(task.worker, task.data)?.foreground === true && !task.data.preload && !task.data.background;
|
||||||
const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload));
|
const foregroundActive = [...this.workers].some(worker => [...worker.operations.values()].some(data => data.name !== "ofdgoSaveSnapshot" && worker.primary && this.owner(worker, data)?.foreground === true && !data.preload && !data.background));
|
||||||
const continuing = task.worker.primary && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID);
|
const continuing = task.worker.primary && [...task.worker.operations.values()].some(data => data.engineID === task.data.engineID);
|
||||||
const available = continuing || active < this.limit || interactive && !foregroundActive && active < this.limit + 1;
|
const available = continuing || active < this.limit || interactive && !foregroundActive && active < this.limit + 1;
|
||||||
if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; }
|
if (busy || snapshot || !available || !task.worker.primary && active && this.memory + (64 << 20) > this.budget) { i++; continue; }
|
||||||
@@ -294,12 +300,15 @@ class BrowserEngine {
|
|||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (data.memory) this.memory = data.memory;
|
if (data.memory) this.memory = data.memory;
|
||||||
if (this.source) this.parallelism = engineScheduler.limit;
|
if (this.source) this.parallelism = engineScheduler.limit;
|
||||||
if (data.id != null && !data.type) this.requests.delete(data.id);
|
const rendered = data.id != null && !data.type && this.requests.delete(data.id);
|
||||||
this.onmessage?.(event);
|
this.onmessage?.(event);
|
||||||
|
if (rendered && data.ok) this.dispatchPages();
|
||||||
};
|
};
|
||||||
this.primary.onerror = event => this.onerror?.(event);
|
this.primary.onerror = event => this.onerror?.(event);
|
||||||
this.primary.onmessageerror = event => this.onmessageerror?.(event);
|
this.primary.onmessageerror = event => this.onmessageerror?.(event);
|
||||||
this.requests = new Map();
|
this.requests = new Map();
|
||||||
|
this.fontRequests = new Map();
|
||||||
|
this.primaryPages = new Set();
|
||||||
this.slots = new Set();
|
this.slots = new Set();
|
||||||
this.sequence = 0;
|
this.sequence = 0;
|
||||||
this.revision = 0;
|
this.revision = 0;
|
||||||
@@ -316,8 +325,9 @@ class BrowserEngine {
|
|||||||
configurePages(source) {
|
configurePages(source) {
|
||||||
const current = this.pageSource;
|
const current = this.pageSource;
|
||||||
const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font);
|
const fonts = (source?.fonts || []).map(font => font?.data !== undefined ? {name:font.name, data:font.data} : font);
|
||||||
|
const sameNames = JSON.stringify(source?.fontNames || []) === JSON.stringify(current?.fontNames || []);
|
||||||
const sameFonts = current && fonts.length === current.fonts.length && fonts.every((font, index) => font === current.fonts[index] || font?.data !== undefined && font.name === current.fonts[index]?.name && font.data === current.fonts[index]?.data);
|
const sameFonts = current && fonts.length === current.fonts.length && fonts.every((font, index) => font === current.fonts[index] || font?.data !== undefined && font.name === current.fonts[index]?.name && font.data === current.fonts[index]?.data);
|
||||||
if (source && current && source.file === current.file && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts) return;
|
if (source && current && source.file === current.file && !!source.pdf === !!current.pdf && (source.document || 0) === current.document && (source.version || "") === current.version && source.annotations === current.annotations && source.revision === current.revision && sameFonts && sameNames) return;
|
||||||
this.invalidatePages();
|
this.invalidatePages();
|
||||||
if (!source || this.closed) return;
|
if (!source || this.closed) return;
|
||||||
this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""};
|
this.pageSource = {...source, fonts, document:source.document || 0, version:source.version || ""};
|
||||||
@@ -340,21 +350,26 @@ class BrowserEngine {
|
|||||||
|
|
||||||
invalidatePages() {
|
invalidatePages() {
|
||||||
this.revision++;
|
this.revision++;
|
||||||
|
this.primaryPages.clear();
|
||||||
this.source = null;
|
this.source = null;
|
||||||
this.pageSource = null;
|
this.pageSource = null;
|
||||||
this.parallelism = 1;
|
this.parallelism = 1;
|
||||||
|
this.startupTime = 0;
|
||||||
|
this.primary.pageTime = 0;
|
||||||
for (const slot of this.slots) this.closeSlot(slot);
|
for (const slot of this.slots) this.closeSlot(slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) {
|
closeSlot(slot, error = new DOMException("页面加载已取消", "AbortError")) {
|
||||||
if (!this.slots.delete(slot)) return;
|
if (!this.slots.delete(slot)) return;
|
||||||
slot.worker.terminate();
|
slot.worker.terminate();
|
||||||
|
for (const [id, request] of this.fontRequests) if (request.worker === slot.worker) this.fontRequests.delete(id);
|
||||||
slot.rejectReady(error);
|
slot.rejectReady(error);
|
||||||
for (const pending of slot.pending.values()) pending.reject(error);
|
for (const pending of slot.pending.values()) pending.reject(error);
|
||||||
slot.pending.clear();
|
slot.pending.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
createSlot(source) {
|
createSlot(source) {
|
||||||
|
const started = performance.now();
|
||||||
const worker = createEngineWorker({page:true, url:this.workerURL});
|
const worker = createEngineWorker({page:true, url:this.workerURL});
|
||||||
worker.setForeground(this.foreground);
|
worker.setForeground(this.foreground);
|
||||||
worker.pausePages(this.pagesPaused);
|
worker.pausePages(this.pagesPaused);
|
||||||
@@ -368,6 +383,12 @@ class BrowserEngine {
|
|||||||
if (this.source) this.parallelism = engineScheduler.limit;
|
if (this.source) this.parallelism = engineScheduler.limit;
|
||||||
if (data.type === "ready") { resolve(); return; }
|
if (data.type === "ready") { resolve(); return; }
|
||||||
if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; }
|
if (data.type === "exit") { this.closeSlot(slot, new Error("页面加载失败")); return; }
|
||||||
|
if (data.type === "font") {
|
||||||
|
const request = `${slot.worker.engineID}:${data.request}`;
|
||||||
|
this.fontRequests.set(request, {worker:slot.worker, request:data.request});
|
||||||
|
this.onmessage?.({data:{...data, id:slot.requestID, request}});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.type) return;
|
if (data.type) return;
|
||||||
const request = slot.pending.get(data.id);
|
const request = slot.pending.get(data.id);
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
@@ -379,9 +400,12 @@ class BrowserEngine {
|
|||||||
});
|
});
|
||||||
this.slots.add(slot);
|
this.slots.add(slot);
|
||||||
slot.ready = ready.then(async () => {
|
slot.ready = ready.then(async () => {
|
||||||
let opened = await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]);
|
let opened = source.pdf
|
||||||
|
? await this.callSlot(slot, "ofdgoOpenPDF", [source.file, source.fonts, null, source.annotations, source.fontNames])
|
||||||
|
: await this.callSlot(slot, "ofdgoOpen", [source.file, source.fonts, source.annotations]);
|
||||||
if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]);
|
if ((source.document || 0) !== (opened.data.documentIndex || 0)) opened = await this.callSlot(slot, "ofdgoSelectDocument", [source.document]);
|
||||||
if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]);
|
if ((source.version || "") !== (opened.data.documentVersion || "")) await this.callSlot(slot, "ofdgoSelectVersion", [source.version || ""]);
|
||||||
|
if (this.slots.has(slot)) this.startupTime = performance.now() - started;
|
||||||
});
|
});
|
||||||
slot.ready.catch(() => {});
|
slot.ready.catch(() => {});
|
||||||
return slot;
|
return slot;
|
||||||
@@ -397,20 +421,59 @@ class BrowserEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageSlot(data) {
|
||||||
|
if (this.primaryPages.has(data.args[0])) return null;
|
||||||
|
const preferred = [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0]));
|
||||||
|
const pending = [...this.requests.values()].filter(slot => slot === null).length - Number(this.requests.has(data.id));
|
||||||
|
if (!preferred && pending <= 0) return null;
|
||||||
|
let slot = preferred || [...this.slots].filter(slot => !slot.busy).sort((a,b) => Math.abs(a.lastPage-data.args[0])-Math.abs(b.lastPage-data.args[0]))[0];
|
||||||
|
const worthwhile = (this.primary.pageTime || 0) * pending >= Math.max(50, this.startupTime || this.primary.openTime || 0);
|
||||||
|
if (!slot && worthwhile && this.foreground && !this.pagesPaused && this.slots.size < this.parallelism - 1
|
||||||
|
&& engineScheduler.memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source);
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchPages() {
|
||||||
|
if (this.closed || !this.source || !this.foreground || this.pagesPaused) return;
|
||||||
|
const draining = engineScheduler.draining;
|
||||||
|
engineScheduler.draining = true;
|
||||||
|
try {
|
||||||
|
const queued = engineScheduler.queue.filter(task => task.worker === this.primary.runtime && task.data.engineID === this.engineID).sort((a,b) => a.order-b.order);
|
||||||
|
for (const task of queued) {
|
||||||
|
if (task.data.name !== "ofdgoRenderPage") break;
|
||||||
|
if (this.requests.get(task.data.id) !== null) continue;
|
||||||
|
let slot;
|
||||||
|
try { slot = this.pageSlot(task.data); } catch { break; }
|
||||||
|
if (!slot) break;
|
||||||
|
engineScheduler.queue.splice(engineScheduler.queue.indexOf(task), 1);
|
||||||
|
this.primary.requests.delete(task.data.id);
|
||||||
|
this.renderSlot(slot, task.data, task.transfer);
|
||||||
|
}
|
||||||
|
} finally { engineScheduler.draining = draining; }
|
||||||
|
}
|
||||||
|
|
||||||
postMessage(data, transfer) {
|
postMessage(data, transfer) {
|
||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
|
if (data.type === "font" && typeof data.request === "string") {
|
||||||
|
const request = this.fontRequests.get(data.request);
|
||||||
|
if (request) {
|
||||||
|
this.fontRequests.delete(data.request);
|
||||||
|
request.worker.postMessage({...data, request:request.request}, transfer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!data.type && data.preload && this.pagesPaused) {
|
if (!data.type && data.preload && this.pagesPaused) {
|
||||||
this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
|
this.onmessage?.({data:{engineID:this.engineID, id:data.id, ok:false, canceled:true, error:"预加载已暂停"}});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.type === "cancel") {
|
if (data.type === "cancel") {
|
||||||
const slot = this.requests.get(data.id);
|
const slot = this.requests.get(data.id);
|
||||||
if (slot) this.closeSlot(slot);
|
if (slot && slot !== this.primary) this.closeSlot(slot);
|
||||||
else this.primary.postMessage(data);
|
else this.primary.postMessage(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data.type && data.name !== "ofdgoRenderPage" && ![
|
if (!data.type && data.name !== "ofdgoRenderPage" && ![
|
||||||
"ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData",
|
"ofdgoLoadPDF", "ofdgoDocumentInfo", "ofdgoDocumentVersions", "ofdgoDocumentNavigation", "ofdgoOpenLinks", "ofdgoOutlineLinks", "ofdgoRenderBackends", "ofdgoSVGFontData",
|
||||||
"ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets",
|
"ofdgoSearchPage", "ofdgoPageTextString", "ofdgoResources", "ofdgoResourceReferences", "ofdgoResourceTargets",
|
||||||
"ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles",
|
"ofdgoResourceLocations", "ofdgoFontPreview", "ofdgoFontFaces", "ofdgoFontFace", "ofdgoMatchFontFiles",
|
||||||
"ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures",
|
"ofdgoImages", "ofdgoStructureTargets", "ofdgoStructureObjects", "ofdgoPackageDocuments", "ofdgoVerifySignatures",
|
||||||
@@ -420,26 +483,30 @@ class BrowserEngine {
|
|||||||
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
|
"ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoDiscardPrint",
|
||||||
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
|
"ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion"
|
||||||
].includes(data.name)) this.invalidatePages();
|
].includes(data.name)) this.invalidatePages();
|
||||||
const preferred = data.name === "ofdgoRenderPage" ? [...this.slots].find(slot => !slot.busy && slot.pages.has(data.args[0])) : null;
|
const slot = data.name === "ofdgoRenderPage" && this.source ? this.pageSlot(data) : null;
|
||||||
if (data.name !== "ofdgoRenderPage" || !this.source || !preferred && ![...this.requests.values()].includes(null)) {
|
if (!slot) {
|
||||||
if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null);
|
if (data.name === "ofdgoRenderPage") this.requests.set(data.id, null);
|
||||||
this.primary.postMessage(data, transfer);
|
this.primary.postMessage(data, transfer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let slot = preferred || [...this.slots].filter(slot => !slot.busy).sort((a,b) => Math.abs(a.lastPage-data.args[0])-Math.abs(b.lastPage-data.args[0]))[0];
|
this.renderSlot(slot, data, transfer);
|
||||||
const memory = engineScheduler.memory;
|
}
|
||||||
if (!slot && this.foreground && !this.pagesPaused && this.slots.size < this.parallelism - 1 && memory + Math.max(128 << 20, this.memory) <= this.budget) slot = this.createSlot(this.source);
|
|
||||||
if (!slot) {
|
renderSlot(slot, data, transfer) {
|
||||||
this.requests.set(data.id, null);
|
|
||||||
this.primary.postMessage(data, transfer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
slot.busy = true;
|
slot.busy = true;
|
||||||
|
slot.requestID = data.id;
|
||||||
this.requests.set(data.id, slot);
|
this.requests.set(data.id, slot);
|
||||||
const revision = this.revision;
|
const revision = this.revision;
|
||||||
let fallback = false;
|
let fallback = false;
|
||||||
slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => {
|
slot.ready.then(() => this.callSlot(slot, data.name, data.args, !!data.preload)).then(result => {
|
||||||
if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError");
|
if (revision !== this.revision) throw new DOMException("页面加载已取消", "AbortError");
|
||||||
|
if (this.source.pdf && ((JSON.parse(result.data.annotations || "null") || []).length || result.data.links?.length)) {
|
||||||
|
this.primaryPages.add(data.args[0]);
|
||||||
|
this.requests.set(data.id, this.primary);
|
||||||
|
fallback = true;
|
||||||
|
this.primary.postMessage(data, transfer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
slot.lastPage = data.args[0];
|
slot.lastPage = data.args[0];
|
||||||
slot.pages.delete(slot.lastPage);
|
slot.pages.delete(slot.lastPage);
|
||||||
slot.pages.add(slot.lastPage);
|
slot.pages.add(slot.lastPage);
|
||||||
@@ -449,7 +516,7 @@ class BrowserEngine {
|
|||||||
if (!this.closed && revision === this.revision && error.name !== "AbortError") {
|
if (!this.closed && revision === this.revision && error.name !== "AbortError") {
|
||||||
this.closeSlot(slot, error);
|
this.closeSlot(slot, error);
|
||||||
this.parallelism = Math.max(1, this.slots.size + 1);
|
this.parallelism = Math.max(1, this.slots.size + 1);
|
||||||
this.requests.set(data.id, null);
|
this.requests.set(data.id, this.primary);
|
||||||
fallback = true;
|
fallback = true;
|
||||||
this.primary.postMessage(data, transfer);
|
this.primary.postMessage(data, transfer);
|
||||||
return;
|
return;
|
||||||
@@ -461,6 +528,7 @@ class BrowserEngine {
|
|||||||
slot.worker.reservation = 0;
|
slot.worker.reservation = 0;
|
||||||
const memory = engineScheduler.memory;
|
const memory = engineScheduler.memory;
|
||||||
if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); }
|
if (memory > this.budget) { this.closeSlot(slot); this.parallelism = Math.max(1, this.slots.size + 1); }
|
||||||
|
this.dispatchPages();
|
||||||
engineScheduler.drain();
|
engineScheduler.drain();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -574,13 +642,13 @@ class NativeHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async decode(value, name = "", key = "", signal) {
|
async decode(value, signal) {
|
||||||
const reads = [];
|
const reads = [];
|
||||||
const visit = (item, field, replace) => {
|
const visit = (item, field, replace) => {
|
||||||
if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return;
|
if (!item || typeof item !== "object" || item instanceof Blob || item instanceof ArrayBuffer || ArrayBuffer.isView(item)) return;
|
||||||
if (item.nativeFile) {
|
if (item.nativeFile) {
|
||||||
const file = new NativeFile(item);
|
const file = new NativeFile(item);
|
||||||
if (field === "file" || name === "ofdgoConvertPDF" && field === "bytes") replace(file);
|
if (field === "file") replace(file);
|
||||||
else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes))));
|
else reads.push(this.readResource(file, signal).then(bytes => replace(field === "blob" ? new Blob([bytes], {type:item.type}) : new Uint8Array(bytes))));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -588,7 +656,7 @@ class NativeHost {
|
|||||||
if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result);
|
if (content && typeof content === "object") visit(content, Array.isArray(item) ? "" : child, result => item[child] = result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
visit(value, key, result => value = result);
|
visit(value, "", result => value = result);
|
||||||
const results = await Promise.allSettled(reads);
|
const results = await Promise.allSettled(reads);
|
||||||
const failed = results.find(result => result.status === "rejected");
|
const failed = results.find(result => result.status === "rejected");
|
||||||
if (failed) throw failed.reason;
|
if (failed) throw failed.reason;
|
||||||
@@ -650,7 +718,7 @@ class NativeHost {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.type === "font") return;
|
if (data.type === "font") return;
|
||||||
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoConvertPDF"].includes(data.name)) {
|
if (["ofdgoDetectFormat", "ofdgoOpen", "ofdgoOpenPDF"].includes(data.name)) {
|
||||||
const input = data.args?.[0];
|
const input = data.args?.[0];
|
||||||
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
|
if (input?.nativeFile && !input.temporary) inputs.add(input.nativeFile);
|
||||||
}
|
}
|
||||||
@@ -683,7 +751,7 @@ class NativeHost {
|
|||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
decoded = await host.decode(result, data.name, "", controller.signal);
|
decoded = await host.decode(result, controller.signal);
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
}
|
}
|
||||||
catch (error) { await host.release(result); throw error; }
|
catch (error) { await host.release(result); throw error; }
|
||||||
|
|||||||
@@ -382,6 +382,7 @@ export class PrintDialog {
|
|||||||
this.root = document.querySelector("#ofdgoPrintDocument") || this.createRoot();
|
this.root = document.querySelector("#ofdgoPrintDocument") || this.createRoot();
|
||||||
this.root.replaceChildren();
|
this.root.replaceChildren();
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
|
style.textContent = this.layout.sides.map((side, i) => `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`).join("");
|
||||||
this.root.append(style);
|
this.root.append(style);
|
||||||
this.status("正在准备纸面", true, 0, this.layout.sides.length);
|
this.status("正在准备纸面", true, 0, this.layout.sides.length);
|
||||||
for (let i = 0; i < this.layout.sides.length; i++) {
|
for (let i = 0; i < this.layout.sides.length; i++) {
|
||||||
@@ -394,7 +395,6 @@ export class PrintDialog {
|
|||||||
img.src = url;
|
img.src = url;
|
||||||
section.append(img);
|
section.append(img);
|
||||||
section.style.cssText = `page:ofdgoPrint${i};width:${side.width}mm;height:${side.height}mm`;
|
section.style.cssText = `page:ofdgoPrint${i};width:${side.width}mm;height:${side.height}mm`;
|
||||||
style.textContent += `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`;
|
|
||||||
this.root.append(section);
|
this.root.append(section);
|
||||||
await img.decode();
|
await img.decode();
|
||||||
if (this.current(sequence)) this.status("正在准备纸面", true, i + 1, this.layout.sides.length);
|
if (this.current(sequence)) this.status("正在准备纸面", true, i + 1, this.layout.sides.length);
|
||||||
|
|||||||
+153
-73
@@ -47,8 +47,9 @@ export function createPageView(context) {
|
|||||||
} else if (state.pageWindow && (!pageView.pageShell(index) || pageView.pageShell(index).style.visibility === "hidden")) {
|
} else if (state.pageWindow && (!pageView.pageShell(index) || pageView.pageShell(index).style.visibility === "hidden")) {
|
||||||
pageView.scrollToPage(index);
|
pageView.scrollToPage(index);
|
||||||
}
|
}
|
||||||
|
pageView.prunePageRenderQueue();
|
||||||
const page = await pageView.renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
|
const page = await pageView.renderFlowPage(index, { throwError: true, openSeq, priority: 0 });
|
||||||
if (openSeq !== state.openSeq || index !== state.pageIndex) {
|
if (!page || openSeq !== state.openSeq || index !== state.pageIndex) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (options.scroll !== false) {
|
if (options.scroll !== false) {
|
||||||
@@ -221,9 +222,14 @@ export function createPageView(context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetPageLoading() {
|
resetPageLoading() {
|
||||||
|
context.state.pageLoadSeq++;
|
||||||
context.releaseMediaPlayers();
|
context.releaseMediaPlayers();
|
||||||
|
if (context.state.pagePresentFrame) cancelAnimationFrame(context.state.pagePresentFrame);
|
||||||
|
context.state.pagePresentFrame = 0;
|
||||||
|
for (const task of context.state.pagePresentQueue.values()) task.resolve(null);
|
||||||
|
context.state.pagePresentQueue.clear();
|
||||||
for (const [id, request] of context.wasmRequests) {
|
for (const [id, request] of context.wasmRequests) {
|
||||||
if (request.tab === context.documentTab() && request.name === "ofdgoRenderPage") context.wasmWorker?.postMessage({ type: "cancel", id });
|
if (request.tab === context.documentTab() && request.name === "ofdgoRenderPage" && (!context.state.sourcePDF || context.state.pdfComplete || context.state.editorInfo)) context.wasmWorker?.postMessage({ type: "cancel", id });
|
||||||
}
|
}
|
||||||
for (const task of context.state.pageInFlight.values()) {
|
for (const task of context.state.pageInFlight.values()) {
|
||||||
task.resolve(null);
|
task.resolve(null);
|
||||||
@@ -286,6 +292,15 @@ export function createPageView(context) {
|
|||||||
unmountPage(index) {
|
unmountPage(index) {
|
||||||
const shell = pageView.pageShell(index);
|
const shell = pageView.pageShell(index);
|
||||||
if (!shell) return;
|
if (!shell) return;
|
||||||
|
if (context.state.editing && shell.classList.contains("rendered")) {
|
||||||
|
const bounds = shell.getBoundingClientRect(), viewport = context.el.viewerPanel.getBoundingClientRect();
|
||||||
|
const margin = context.state.pageWindow?.overscan ?? 600;
|
||||||
|
const bottom = Math.max(bounds.bottom, bounds.top + shell.scrollHeight);
|
||||||
|
if (bottom >= viewport.top - margin && bounds.top <= viewport.bottom + margin) {
|
||||||
|
context.state.selectedPages.add(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
||||||
@@ -301,29 +316,28 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
async renderFlowPage(index, options = {}) {
|
async renderFlowPage(index, options = {}) {
|
||||||
const openSeq = options.openSeq || context.state.openSeq;
|
const openSeq = options.openSeq || context.state.openSeq;
|
||||||
if (openSeq !== context.state.openSeq) {
|
const loadSeq = context.state.pageLoadSeq;
|
||||||
|
const current = () => openSeq === context.state.openSeq && loadSeq === context.state.pageLoadSeq;
|
||||||
|
if (!current()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const page = await pageView.loadPageData(index, { openSeq, priority: options.priority ?? 2,
|
const page = await pageView.loadPageData(index, { openSeq, priority: options.priority ?? 2, view: true,
|
||||||
refresh: context.state.editing && context.state.pageCache.get(index)?.previewOnly });
|
refresh: context.state.editing && context.state.pageCache.get(index)?.previewOnly });
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (!current()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (page) {
|
if (page) {
|
||||||
const shell = pageView.pageShell(index);
|
await pageView.queuePagePresentation(index, page, openSeq, true);
|
||||||
if (shell && !shell.classList.contains("rendered") && (!context.state.pageObserver || context.state.visiblePages.has(index) || index === context.state.pageIndex)) {
|
|
||||||
pageView.mountPageSVG(index, page, openSeq);
|
|
||||||
}
|
|
||||||
pageView.updateThumbnail(index, openSeq);
|
|
||||||
}
|
}
|
||||||
|
if (!current()) return null;
|
||||||
pageView.trimPageCache();
|
pageView.trimPageCache();
|
||||||
return page;
|
return page;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (openSeq === context.state.openSeq) {
|
if (current()) {
|
||||||
pageView.markFlowPageError(index, err);
|
pageView.markFlowPageError(index, err);
|
||||||
}
|
}
|
||||||
if (options.throwError && openSeq === context.state.openSeq) {
|
if (options.throwError && current()) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -332,6 +346,7 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
loadPageData(index, options = {}) {
|
loadPageData(index, options = {}) {
|
||||||
const openSeq = options.openSeq || context.state.openSeq;
|
const openSeq = options.openSeq || context.state.openSeq;
|
||||||
|
const loadSeq = context.state.pageLoadSeq;
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (openSeq !== context.state.openSeq) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
@@ -340,7 +355,8 @@ export function createPageView(context) {
|
|||||||
const current = context.state.pageInFlight.get(key);
|
const current = context.state.pageInFlight.get(key);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.priority = Math.min(current.priority, priority);
|
current.priority = Math.min(current.priority, priority);
|
||||||
if (current.preview && !options.preview) return current.promise.then(() => openSeq === context.state.openSeq ? pageView.loadPageData(index, options) : null);
|
current.view &&= !!options.view;
|
||||||
|
if (current.preview && !options.preview) return current.promise.then(page => page && openSeq === context.state.openSeq && loadSeq === context.state.pageLoadSeq ? pageView.loadPageData(index, options) : null);
|
||||||
return current.promise;
|
return current.promise;
|
||||||
}
|
}
|
||||||
if (!options.refresh && context.state.pageCache.has(index) && (options.preview || !context.state.pageCache.get(index).previewOnly)) {
|
if (!options.refresh && context.state.pageCache.has(index) && (options.preview || !context.state.pageCache.get(index).previewOnly)) {
|
||||||
@@ -355,7 +371,7 @@ export function createPageView(context) {
|
|||||||
resolve = done;
|
resolve = done;
|
||||||
reject = fail;
|
reject = fail;
|
||||||
});
|
});
|
||||||
const task = { key, index, openSeq, priority, preview: !!options.preview, refresh: options.refresh, resolve, reject, promise };
|
const task = { key, index, openSeq, priority, view: !!options.view, preview: !!options.preview, refresh: options.refresh, resolve, reject, promise };
|
||||||
context.state.pageInFlight.set(key, task);
|
context.state.pageInFlight.set(key, task);
|
||||||
context.state.pageRenderQueue.push(task);
|
context.state.pageRenderQueue.push(task);
|
||||||
pageView.schedulePageRender();
|
pageView.schedulePageRender();
|
||||||
@@ -370,16 +386,16 @@ export function createPageView(context) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.state.pageRenderRunning = true;
|
context.state.pageRenderRunning = true;
|
||||||
const tab = context.documentTab(), openSeq = tab.state.openSeq;
|
const tab = context.documentTab(), openSeq = tab.state.openSeq, loadSeq = tab.state.pageLoadSeq;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (context.activeTab === tab && openSeq === tab.state.openSeq) pageView.processPageRenderQueue();
|
if (context.activeTab === tab && openSeq === tab.state.openSeq && loadSeq === tab.state.pageLoadSeq) pageView.processPageRenderQueue();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async processPageRenderQueue() {
|
async processPageRenderQueue() {
|
||||||
const tab = context.documentTab(), openSeq = tab.state.openSeq;
|
const tab = context.documentTab(), openSeq = tab.state.openSeq, loadSeq = tab.state.pageLoadSeq;
|
||||||
await context.nextFrame();
|
await context.nextFrame();
|
||||||
if (context.activeTab !== tab || openSeq !== tab.state.openSeq) return;
|
if (context.activeTab !== tab || openSeq !== tab.state.openSeq || loadSeq !== tab.state.pageLoadSeq) return;
|
||||||
const available = (nativeHost?.parallelism || context.wasmWorker?.parallelism || 1) - tab.state.pageRenderWorkers;
|
const available = (nativeHost?.parallelism || context.wasmWorker?.parallelism || 1) - tab.state.pageRenderWorkers;
|
||||||
await Promise.all(Array.from({ length: Math.max(0, available) }, () => pageView.processPageRenderTasks()));
|
await Promise.all(Array.from({ length: Math.max(0, available) }, () => pageView.processPageRenderTasks()));
|
||||||
},
|
},
|
||||||
@@ -410,10 +426,10 @@ export function createPageView(context) {
|
|||||||
task.resolve(null);
|
task.resolve(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const scope = state.composite;
|
if (state.editing) await context.loadEditorMembers(tab, page, task.index);
|
||||||
if (scope?.index === task.index) {
|
if (context.activeTab !== tab || state.pageInFlight.get(task.key) !== task || task.openSeq !== state.openSeq) {
|
||||||
const objects = await context.callWASM("ofdgoCompositeObjects", task.index, scope.key);
|
task.resolve(null);
|
||||||
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
|
continue;
|
||||||
}
|
}
|
||||||
await pageView.loadSVGFonts(page.fonts, task.openSeq);
|
await pageView.loadSVGFonts(page.fonts, task.openSeq);
|
||||||
for (const font of page.fonts) delete font.data;
|
for (const font of page.fonts) delete font.data;
|
||||||
@@ -460,6 +476,7 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
trimPageCache() {
|
trimPageCache() {
|
||||||
const protectedPages = new Set([context.state.pageIndex, ...context.state.visiblePages, ...context.state.visibleThumbnails, ...context.state.selectedPages]);
|
const protectedPages = new Set([context.state.pageIndex, ...context.state.visiblePages, ...context.state.visibleThumbnails, ...context.state.selectedPages]);
|
||||||
|
for (const index of context.state.pagePresentQueue.keys()) protectedPages.add(index);
|
||||||
for (const view of [context.state.pageWindow, context.state.thumbnailWindow]) {
|
for (const view of [context.state.pageWindow, context.state.thumbnailWindow]) {
|
||||||
for (const index of view?.nodes.keys() || []) protectedPages.add(index);
|
for (const index of view?.nodes.keys() || []) protectedPages.add(index);
|
||||||
}
|
}
|
||||||
@@ -521,33 +538,37 @@ export function createPageView(context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadSVGFonts(fonts, openSeq) {
|
async loadSVGFonts(fonts, openSeq) {
|
||||||
const state = context.state;
|
const state = context.state, loadSeq = state.pageLoadSeq;
|
||||||
for (const font of fonts) {
|
const current = () => state === context.state && openSeq === state.openSeq && loadSeq === state.pageLoadSeq;
|
||||||
if (openSeq !== context.state.openSeq) {
|
for (let offset = 0; offset < fonts.length && current(); offset += 4) {
|
||||||
return;
|
const results = await Promise.allSettled(fonts.slice(offset, offset + 4).map(async font => {
|
||||||
}
|
if (state.svgFonts.has(font.name)) return;
|
||||||
if (state.svgFonts.has(font.name)) {
|
const key = `${openSeq}:${loadSeq}:${font.name}`;
|
||||||
continue;
|
while (current() && !state.svgFonts.has(font.name) && !state.svgFontLoads.has(key) && state.svgFontLoads.size >= 4) {
|
||||||
}
|
await Promise.race([...state.svgFontLoads.values()].map(loading => loading.catch(() => {})));
|
||||||
const key = `${openSeq}:${font.name}`;
|
}
|
||||||
let loading = state.svgFontLoads.get(key);
|
if (!current() || state.svgFonts.has(font.name)) return;
|
||||||
if (!loading) {
|
let loading = state.svgFontLoads.get(key);
|
||||||
loading = pageView.loadSVGFont(font, openSeq);
|
if (!loading) {
|
||||||
state.svgFontLoads.set(key, loading);
|
loading = pageView.loadSVGFont(font, openSeq, loadSeq);
|
||||||
}
|
state.svgFontLoads.set(key, loading);
|
||||||
try { await loading; }
|
}
|
||||||
finally { if (state.svgFontLoads.get(key) === loading) state.svgFontLoads.delete(key); }
|
try { await loading; }
|
||||||
|
finally { if (state.svgFontLoads.get(key) === loading) state.svgFontLoads.delete(key); }
|
||||||
|
}));
|
||||||
|
const failed = results.find(result => result.status === "rejected");
|
||||||
|
if (failed) throw failed.reason;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSVGFont(font, openSeq) {
|
async loadSVGFont(font, openSeq, loadSeq = context.state.pageLoadSeq) {
|
||||||
const data = font.data ? { bytes: font.data } : await context.callWASM("ofdgoSVGFontData", font.name);
|
const data = font.data ? { bytes: font.data } : await context.callWASM("ofdgoSVGFontData", font.name);
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (openSeq !== context.state.openSeq || loadSeq !== context.state.pageLoadSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style });
|
const face = new FontFace(font.name, data.bytes, { weight: String(font.weight), style: font.style });
|
||||||
await face.load();
|
await face.load();
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (openSeq !== context.state.openSeq || loadSeq !== context.state.pageLoadSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
@@ -556,10 +577,73 @@ export function createPageView(context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
comparePageRenderTask(a, b) {
|
comparePageRenderTask(a, b) {
|
||||||
return a.priority - b.priority || Math.abs(a.index - context.state.pageIndex) - Math.abs(b.index - context.state.pageIndex);
|
return pageView.pageRenderPriority(a) - pageView.pageRenderPriority(b) || Math.abs(a.index - context.state.pageIndex) - Math.abs(b.index - context.state.pageIndex);
|
||||||
},
|
},
|
||||||
|
|
||||||
mountPageSVG(index, page, openSeq = context.state.openSeq) {
|
pageRenderPriority(task, state = context.state) {
|
||||||
|
if (!task.view || task.refresh) return task.priority;
|
||||||
|
if (task.index === state.pageIndex) return 0;
|
||||||
|
if (Math.floor(task.index / state.pageLayout) === Math.floor(state.pageIndex / state.pageLayout)) return 1;
|
||||||
|
if (!task.preview) {
|
||||||
|
if (state.visiblePages.has(task.index)) return 2;
|
||||||
|
if (state.pageWindow?.nodes.has(task.index)) return 3;
|
||||||
|
}
|
||||||
|
if (state.visibleThumbnails.has(task.index) || state.thumbnailWindow?.nodes.has(task.index)) return 5;
|
||||||
|
return 6;
|
||||||
|
},
|
||||||
|
|
||||||
|
queuePagePresentation(index, page, openSeq, flow = false) {
|
||||||
|
const state = context.state;
|
||||||
|
if (openSeq !== state.openSeq || state.pageCache.get(index) !== page) return Promise.resolve(null);
|
||||||
|
let shell = flow ? pageView.pageShell(index) : null;
|
||||||
|
let thumb = context.el.pageList.querySelector(`[data-page-index="${index}"] .thumb-paper`);
|
||||||
|
if (shell?.classList.contains("rendered") || state.pageObserver && !state.visiblePages.has(index) && index !== state.pageIndex) shell = null;
|
||||||
|
if (thumb?.dataset.renderKey === `${openSeq}:${index}` || state.thumbnailObserver && !state.visibleThumbnails.has(index) && index !== state.pageIndex) thumb = null;
|
||||||
|
if (!shell && !thumb) return Promise.resolve(null);
|
||||||
|
const queue = state.pagePresentQueue, current = queue.get(index);
|
||||||
|
if (current?.page === page) {
|
||||||
|
if (shell) { current.shell = shell; current.preview = false; }
|
||||||
|
current.thumb = thumb;
|
||||||
|
return current.promise;
|
||||||
|
}
|
||||||
|
current?.resolve(null);
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
|
||||||
|
queue.set(index, { index, page, openSeq, shell, thumb, view: true, preview: !shell, resolve, reject, promise });
|
||||||
|
pageView.schedulePagePresentation();
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
schedulePagePresentation() {
|
||||||
|
const tab = context.documentTab(), state = tab.state, openSeq = state.openSeq, loadSeq = state.pageLoadSeq;
|
||||||
|
if (state.pagePresentFrame || !state.pagePresentQueue.size) return;
|
||||||
|
state.pagePresentFrame = requestAnimationFrame(() => {
|
||||||
|
if (context.activeTab !== tab || openSeq !== state.openSeq || loadSeq !== state.pageLoadSeq) return;
|
||||||
|
state.pagePresentFrame = 0;
|
||||||
|
const started = performance.now();
|
||||||
|
do {
|
||||||
|
const task = [...state.pagePresentQueue.values()].sort(pageView.comparePageRenderTask)[0];
|
||||||
|
try {
|
||||||
|
if (state.pageCache.get(task.index) === task.page) {
|
||||||
|
if (task.shell && pageView.pageShell(task.index) === task.shell && !task.shell.classList.contains("rendered")
|
||||||
|
&& (!state.pageObserver || state.visiblePages.has(task.index) || task.index === state.pageIndex)) {
|
||||||
|
task.text ||= pageView.createTextLayerParts(task.page.text);
|
||||||
|
let text;
|
||||||
|
do { text = task.text.next(); } while (!text.done && performance.now() - started < 6);
|
||||||
|
if (!text.done) break;
|
||||||
|
pageView.mountPageSVG(task.index, task.page, task.openSeq, text.value);
|
||||||
|
}
|
||||||
|
if (task.thumb && context.el.pageList.querySelector(`[data-page-index="${task.index}"] .thumb-paper`) === task.thumb) pageView.updateThumbnail(task.index, task.openSeq);
|
||||||
|
}
|
||||||
|
task.resolve(null);
|
||||||
|
} catch (err) { task.reject(err); }
|
||||||
|
state.pagePresentQueue.delete(task.index);
|
||||||
|
} while (state.pagePresentQueue.size && performance.now() - started < 6);
|
||||||
|
pageView.schedulePagePresentation();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
mountPageSVG(index, page, openSeq = context.state.openSeq, textLayer = null) {
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (openSeq !== context.state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -571,7 +655,7 @@ export function createPageView(context) {
|
|||||||
const svg = pageView.parseSVG(page.svg, `p${openSeq}-${index}`);
|
const svg = pageView.parseSVG(page.svg, `p${openSeq}-${index}`);
|
||||||
svg.classList.add("ofd-svg");
|
svg.classList.add("ofd-svg");
|
||||||
surface.replaceChildren(svg);
|
surface.replaceChildren(svg);
|
||||||
surface.append(pageView.createTextLayer(page.text));
|
surface.append(textLayer || pageView.createTextLayer(page.text));
|
||||||
if (context.state.editorInfo && pageView.displayMode() === "svg") {
|
if (context.state.editorInfo && pageView.displayMode() === "svg") {
|
||||||
context.mountEditorObjects(index, page, surface);
|
context.mountEditorObjects(index, page, surface);
|
||||||
}
|
}
|
||||||
@@ -698,7 +782,7 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
prunePageRenderQueue() {
|
prunePageRenderQueue() {
|
||||||
context.state.pageRenderQueue = context.state.pageRenderQueue.filter(task => {
|
context.state.pageRenderQueue = context.state.pageRenderQueue.filter(task => {
|
||||||
if (task.priority < 2 || Math.abs(task.index - context.state.pageIndex) <= 2
|
if (!task.view || task.refresh || Math.abs(task.index - context.state.pageIndex) <= 2
|
||||||
|| context.state.pageWindow?.nodes.has(task.index) || context.state.thumbnailWindow?.nodes.has(task.index)) return true;
|
|| context.state.pageWindow?.nodes.has(task.index) || context.state.thumbnailWindow?.nodes.has(task.index)) return true;
|
||||||
context.state.pageInFlight.delete(task.key);
|
context.state.pageInFlight.delete(task.key);
|
||||||
task.resolve(null);
|
task.resolve(null);
|
||||||
@@ -742,8 +826,8 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
setCurrentPage(index) {
|
setCurrentPage(index) {
|
||||||
if (index !== context.state.pageIndex && !context.state.openActionTask?.navigating) context.state.openActionTask = null;
|
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;
|
context.state.pageIndex = index;
|
||||||
|
pageView.prunePageRenderQueue();
|
||||||
if (context.state.editing) context.state.openActionPage = context.state.doc?.pages[index]?.id;
|
if (context.state.editing) context.state.openActionPage = context.state.doc?.pages[index]?.id;
|
||||||
if (context.state.fitMode === "width" && context.state.pageLayout === 1) {
|
if (context.state.fitMode === "width" && context.state.pageLayout === 1) {
|
||||||
context.state.scale = pageView.fitWidthScale(pageView.currentPageInfo());
|
context.state.scale = pageView.fitWidthScale(pageView.currentPageInfo());
|
||||||
@@ -1056,7 +1140,6 @@ export function createPageView(context) {
|
|||||||
|
|
||||||
resetThumbnails() {
|
resetThumbnails() {
|
||||||
context.state.visibleThumbnails.clear();
|
context.state.visibleThumbnails.clear();
|
||||||
context.state.thumbnailInFlight.clear();
|
|
||||||
if (context.state.thumbnailObserver) {
|
if (context.state.thumbnailObserver) {
|
||||||
context.state.thumbnailObserver.disconnect();
|
context.state.thumbnailObserver.disconnect();
|
||||||
context.state.thumbnailObserver = null;
|
context.state.thumbnailObserver = null;
|
||||||
@@ -1156,34 +1239,23 @@ export function createPageView(context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async renderThumbnail(index, openSeq = context.state.openSeq) {
|
async renderThumbnail(index, openSeq = context.state.openSeq) {
|
||||||
if (openSeq !== context.state.openSeq || !context.state.doc) {
|
const loadSeq = context.state.pageLoadSeq;
|
||||||
|
const current = () => openSeq === context.state.openSeq && loadSeq === context.state.pageLoadSeq;
|
||||||
|
if (!current() || !context.state.doc) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (context.state.pageCache.has(index)) {
|
|
||||||
pageView.updateThumbnail(index, openSeq);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const key = `${openSeq}:${index}`;
|
|
||||||
if (context.state.thumbnailInFlight.has(key)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
context.state.thumbnailInFlight.add(key);
|
|
||||||
try {
|
try {
|
||||||
const page = await pageView.loadPageData(index, { openSeq, priority: 5, preview: true });
|
const page = await pageView.loadPageData(index, { openSeq, priority: 5, preview: true, view: true });
|
||||||
if (openSeq !== context.state.openSeq) {
|
if (!current() || !page) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!page) {
|
await pageView.queuePagePresentation(index, page, openSeq);
|
||||||
return;
|
if (!current()) return;
|
||||||
}
|
|
||||||
pageView.updateThumbnail(index, openSeq);
|
|
||||||
pageView.trimPageCache();
|
pageView.trimPageCache();
|
||||||
} catch {
|
} catch {
|
||||||
if (openSeq === context.state.openSeq) {
|
if (current()) {
|
||||||
pageView.markThumbnailError(index);
|
pageView.markThumbnailError(index);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
context.state.thumbnailInFlight.delete(key);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1565,12 +1637,11 @@ export function createPageView(context) {
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
const attrs = ["clip-path", "fill", "filter", "href", "marker-end", "marker-mid", "marker-start", "mask", "stroke", "style", "xlink:href"];
|
const attrs = ["clip-path", "fill", "filter", "href", "marker-end", "marker-mid", "marker-start", "mask", "stroke", "style", "xlink:href"];
|
||||||
for (const attr of attrs) {
|
const selector = attrs.map(attr => `[${attr === "xlink:href" ? "*|href" : attr}*="#"]`).join(",");
|
||||||
for (const node of svg.querySelectorAll(`[${attr === "xlink:href" ? "*|href" : attr}*="#"]`)) {
|
for (const node of svg.querySelectorAll(selector)) {
|
||||||
|
for (const attr of attrs) {
|
||||||
const value = node.getAttribute(attr);
|
const value = node.getAttribute(attr);
|
||||||
if (!value) {
|
if (!value?.includes("#")) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const next = replaceRef(value);
|
const next = replaceRef(value);
|
||||||
if (next !== value) {
|
if (next !== value) {
|
||||||
node.setAttribute(attr, next);
|
node.setAttribute(attr, next);
|
||||||
@@ -1580,11 +1651,19 @@ export function createPageView(context) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createTextLayer(text) {
|
createTextLayer(text) {
|
||||||
|
const parts = pageView.createTextLayerParts(text);
|
||||||
|
let next;
|
||||||
|
do { next = parts.next(); } while (!next.done);
|
||||||
|
return next.value;
|
||||||
|
},
|
||||||
|
|
||||||
|
*createTextLayerParts(text) {
|
||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
layer.className = "text-layer";
|
layer.className = "text-layer";
|
||||||
const measure = textMeasure ||= document.createElement("canvas").getContext("2d");
|
const measure = textMeasure ||= document.createElement("canvas").getContext("2d");
|
||||||
measure.font = "100px sans-serif";
|
measure.font = "100px sans-serif";
|
||||||
const widths = new Map();
|
const widths = new Map();
|
||||||
|
let count = 0;
|
||||||
for (const run of text.runs || []) {
|
for (const run of text.runs || []) {
|
||||||
const line = document.createElement("span");
|
const line = document.createElement("span");
|
||||||
line.className = "text-run";
|
line.className = "text-run";
|
||||||
@@ -1602,10 +1681,11 @@ export function createPageView(context) {
|
|||||||
width = measure.measureText(span.textContent).width || 1;
|
width = measure.measureText(span.textContent).width || 1;
|
||||||
widths.set(span.textContent, width);
|
widths.set(span.textContent, width);
|
||||||
}
|
}
|
||||||
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
|
const [a, b, c, d, e, f] = item.matrix;
|
||||||
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
|
span.style.transform = `matrix(${a * MM_TO_PX / width}, ${b * MM_TO_PX / width}, ${c * MM_TO_PX / 100}, ${d * MM_TO_PX / 100}, ${e * MM_TO_PX}, ${f * MM_TO_PX})`;
|
||||||
line.append(span);
|
line.append(span);
|
||||||
end = item.end;
|
end = item.end;
|
||||||
|
if (++count % 128 === 0) yield;
|
||||||
}
|
}
|
||||||
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
|
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
|
||||||
if (breaks) {
|
if (breaks) {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ self.onmessage = ({ data }) => {
|
|||||||
if (engine.closed) return;
|
if (engine.closed) return;
|
||||||
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
|
const opened = JSON.parse(globalThis.ofdgoEngineOpen(engineID));
|
||||||
if (!opened.ok) throw new Error(opened.error);
|
if (!opened.ok) throw new Error(opened.error);
|
||||||
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends"));
|
const backends = JSON.parse(await globalThis.ofdgoEngineCall(engineID, "ofdgoRenderBackends", null));
|
||||||
if (!backends.ok) throw new Error(backends.error);
|
if (!backends.ok) throw new Error(backends.error);
|
||||||
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
|
if (!engine.closed) self.postMessage({ engineID, type: "ready", backends: backends.data, memory: engineMemory.buffer.byteLength });
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -141,7 +141,7 @@ async function handleBatchMessage(engine, { id, name, args }) {
|
|||||||
if (name === "ofdgoConvertFile") {
|
if (name === "ofdgoConvertFile") {
|
||||||
const [file, options, fonts, destination] = args;
|
const [file, options, fonts, destination] = args;
|
||||||
credentials = options.credentials;
|
credentials = options.credentials;
|
||||||
const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats")).data;
|
const formats = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoOutputFormats", null)).data;
|
||||||
const format = formats.find(item => item.value === options.format);
|
const format = formats.find(item => item.value === options.format);
|
||||||
if (!format) throw new Error("不支持此格式");
|
if (!format) throw new Error("不支持此格式");
|
||||||
root = destination;
|
root = destination;
|
||||||
@@ -178,7 +178,7 @@ async function handleBatchMessage(engine, { id, name, args }) {
|
|||||||
chunks = [];
|
chunks = [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", read(file), file.size, options, write,
|
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoConvertFile", done => finish(done), read(file), file.size, options, write,
|
||||||
(phase, completed, total, done) => {
|
(phase, completed, total, done) => {
|
||||||
postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
|
postMessage({ id, type: "conversion", phase, completed, total, memory: engineMemory.buffer.byteLength });
|
||||||
finish(done);
|
finish(done);
|
||||||
@@ -188,7 +188,7 @@ async function handleBatchMessage(engine, { id, name, args }) {
|
|||||||
} else {
|
} else {
|
||||||
const [entries, handle, compression] = args;
|
const [entries, handle, compression] = args;
|
||||||
if (handle) output = await handle.createWritable();
|
if (handle) output = await handle.createWritable();
|
||||||
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
payload = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPackFiles", done => finish(done), entries.map(entry => ({ name: entry.name, size: entry.file.size })),
|
||||||
index => read(entries[index].file), write,
|
index => read(entries[index].file), write,
|
||||||
(completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
|
(completed, total, done) => { postMessage({ id, type: "conversion", phase: "pack", completed, total, memory: engineMemory.buffer.byteLength }); finish(done); },
|
||||||
done => finish(done), compression);
|
done => finish(done), compression);
|
||||||
@@ -235,17 +235,21 @@ async function handleMessage(engine, { id, name, args }) {
|
|||||||
let saveStamp;
|
let saveStamp;
|
||||||
let channel;
|
let channel;
|
||||||
const signal = operations.get(id)?.signal;
|
const signal = operations.get(id)?.signal;
|
||||||
const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoConvertPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
|
const cancellable = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave", "ofdgoSaveSnapshot", "ofdgoImportDocuments", "ofdgoImportImages", "ofdgoExportImages", "ofdgoOpenPDF", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned", "ofdgoImportPages"].includes(name);
|
||||||
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages" || name === "ofdgoImportDocuments";
|
||||||
const converting = name === "ofdgoConvertPDF";
|
const converting = name === "ofdgoOpenPDF";
|
||||||
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
const saving = name === "ofdgoSaveDocument" || name === "ofdgoSaveSnapshot" || name === "ofdgoSaveSigned";
|
||||||
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
|
const resources = ["ofdgoPreparePrint", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF", "ofdgoResources", "ofdgoPrepareSave"].includes(name);
|
||||||
|
if (engine.pdf) {
|
||||||
|
const checked = JSON.parse(await globalThis.ofdgoEngineCall(engine.id, "ofdgoCheckDocument", null, name));
|
||||||
|
if (!checked.ok) { postMessage({id, ...checked}); operations.delete(id); return; }
|
||||||
|
}
|
||||||
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
|
const key = name === "ofdgoOpen" ? args[3]?.key : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.key : null;
|
||||||
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
|
const keyPassword = name === "ofdgoOpen" ? args[3]?.keyPassword : name === "ofdgoSaveSigned" || name === "ofdgoLoadImport" ? args[1]?.keyPassword : null;
|
||||||
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
|
const password = converting ? args[2] : name === "ofdgoSetEncryption" ? args[0]?.password : name === "ofdgoOpen" ? args[3]?.password : name === "ofdgoLoadImport" ? args[1]?.password : null;
|
||||||
try {
|
try {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (["ofdgoOpen", "ofdgoLoadImport", "ofdgoConvertPDF"].includes(name) && args[0] instanceof Blob) {
|
if (["ofdgoOpen", "ofdgoOpenPDF", "ofdgoLoadImport"].includes(name) && args[0] instanceof Blob) {
|
||||||
const file = args[0], reader = new FileReaderSync();
|
const file = args[0], reader = new FileReaderSync();
|
||||||
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
|
args[0] = {size: file.size, read: (offset, size) => new Uint8Array(reader.readAsArrayBuffer(file.slice(offset, offset + size)))};
|
||||||
}
|
}
|
||||||
@@ -337,9 +341,26 @@ async function handleMessage(engine, { id, name, args }) {
|
|||||||
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
|
if (!saving && args.some(value => value?.mode !== undefined)) args.push(done => finish(done));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const payload = await globalThis.ofdgoEngineCall(engine.id, name, ...args);
|
if (engine.pdf && name !== "ofdgoOpenPDF") {
|
||||||
|
channel ||= new MessageChannel();
|
||||||
|
const context = await globalThis.ofdgoEngineCall(engine.id, "ofdgoPDFContext", null, (phase, completed, total, done) => {
|
||||||
|
postMessage({id, type: "conversion", phase, completed, total});
|
||||||
|
channel.port1.onmessage = () => done("", signal.aborted);
|
||||||
|
channel.port2.postMessage(null);
|
||||||
|
}, (name, done) => requestFont(engine, id, signal, name, done));
|
||||||
|
engine.pdf = JSON.parse(context).data === true;
|
||||||
|
}
|
||||||
|
const started = name === "ofdgoOpen" || name === "ofdgoRenderPage" ? performance.now() : null;
|
||||||
|
const payload = await globalThis.ofdgoEngineCall(engine.id, name, done => {
|
||||||
|
channel ||= new MessageChannel();
|
||||||
|
channel.port1.onmessage = () => done("", signal.aborted);
|
||||||
|
channel.port2.postMessage(null);
|
||||||
|
}, ...args);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
const result = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||||
|
if (result.ok && name === "ofdgoOpenPDF") engine.pdf = true;
|
||||||
|
if (result.ok && (name === "ofdgoOpen" || name === "ofdgoLoadPDF" && result.data.done)) engine.pdf = false;
|
||||||
|
if (started !== null && result.ok) result.elapsed = performance.now() - started;
|
||||||
if (cancellable && !importing && !converting && !resources && result.ok) {
|
if (cancellable && !importing && !converting && !resources && result.ok) {
|
||||||
result.data.size = size;
|
result.data.size = size;
|
||||||
if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
|
if (name !== "ofdgoExportMedia") postMessage({ id, type: "export", stage: "save" });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ require (
|
|||||||
github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6
|
github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6
|
||||||
github.com/xiaoqidun/j2kgo v1.0.2
|
github.com/xiaoqidun/j2kgo v1.0.2
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8
|
github.com/xiaoqidun/jbig2 v1.1.8
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5
|
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0
|
||||||
golang.org/x/crypto v0.58.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.47.0
|
golang.org/x/image v0.47.0
|
||||||
golang.org/x/sys v0.49.0
|
golang.org/x/sys v0.49.0
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw
|
|||||||
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA=
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo=
|
||||||
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA=
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5 h1:TudqqH4zfCt7jrBTN/yhMQqa7BvkozYJoF9q+T6FE6s=
|
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU=
|
||||||
github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
|
github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package font
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||||
|
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||||
type Face struct {
|
type Face struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
@@ -30,6 +33,11 @@ type Face struct {
|
|||||||
Style string `json:"style"`
|
Style string `json:"style"`
|
||||||
PostScriptName string `json:"postscriptName"`
|
PostScriptName string `json:"postscriptName"`
|
||||||
Names []string `json:"names"`
|
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 从名称表读取字体信息
|
// FaceInfo 从名称表读取字体信息
|
||||||
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
|||||||
}
|
}
|
||||||
return name(ids...)
|
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 校验字体或集合头并读取字体数量
|
// FileCount 校验字体或集合头并读取字体数量
|
||||||
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
face := TableFaceInfo(tables, index, platform, language)
|
||||||
if face.FullName == "" {
|
if face.FullName == "" {
|
||||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
|||||||
values := make(map[[4]uint16]string)
|
values := make(map[[4]uint16]string)
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
switch record.Name {
|
switch record.Name {
|
||||||
case 1, 2, 4, 6, 16, 17:
|
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+916
-320
File diff suppressed because it is too large.
Load diff
@@ -191,7 +191,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
if e.closed {
|
if e.closed {
|
||||||
return nativeFailure(fmt.Errorf("native engine is closed"))
|
return nativeFailure(fmt.Errorf("native engine is closed"))
|
||||||
}
|
}
|
||||||
if name == "ofdgoRenderPage" && e.currentSession != nil {
|
if name == "ofdgoRenderPage" && e.currentSession != nil && e.pdf == nil {
|
||||||
defer func() {
|
defer func() {
|
||||||
if failure := recover(); failure != nil {
|
if failure := recover(); failure != nil {
|
||||||
result = nativeFailure(fmt.Errorf("%v", failure))
|
result = nativeFailure(fmt.Errorf("%v", failure))
|
||||||
@@ -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 {
|
if result != nil && result["ok"] == true && ctx.Err() == nil {
|
||||||
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
|
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
|
||||||
clear(worker.pageCache)
|
clear(worker.pageCache)
|
||||||
clear(worker.textCache)
|
worker.textCache.Clear()
|
||||||
worker.textOrder = nil
|
|
||||||
clear(worker.svgFonts)
|
clear(worker.svgFonts)
|
||||||
e.renderIdle = append(e.renderIdle, worker)
|
e.renderIdle = append(e.renderIdle, worker)
|
||||||
}
|
}
|
||||||
@@ -311,7 +310,14 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
if fn == nil {
|
if fn == nil {
|
||||||
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
|
return nativeFailure(fmt.Errorf("unknown operation: %s", name))
|
||||||
}
|
}
|
||||||
|
if err := e.checkDocument(name); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
|
call := &nativeCall{engine: e, ctx: ctx, emit: emit}
|
||||||
|
if e.pdf != nil {
|
||||||
|
e.pdf.progress = platformValueOf(call.progress("conversion", "", true))
|
||||||
|
e.pdf.font = platformValueOf(nativeCallback(call.font))
|
||||||
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := call.abort(); err != nil {
|
if err := call.abort(); err != nil {
|
||||||
if result == nil || result["ok"] == true {
|
if result == nil || result["ok"] == true {
|
||||||
@@ -329,6 +335,9 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
|||||||
for i, arg := range prepared {
|
for i, arg := range prepared {
|
||||||
values[i] = platformValueOf(arg)
|
values[i] = platformValueOf(arg)
|
||||||
}
|
}
|
||||||
|
if err := e.preparePDF(name, values); err != nil {
|
||||||
|
return nativeFailure(err)
|
||||||
|
}
|
||||||
data, err := e.safeCall(fn, values)
|
data, err := e.safeCall(fn, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nativeFailure(err)
|
return nativeFailure(err)
|
||||||
@@ -357,6 +366,7 @@ func (e *NativeEngine) Close() error {
|
|||||||
e.gate <- struct{}{}
|
e.gate <- struct{}{}
|
||||||
defer func() { <-e.gate }()
|
defer func() { <-e.gate }()
|
||||||
e.closed = true
|
e.closed = true
|
||||||
|
e.clearPDF()
|
||||||
_, _ = e.discardPrint(nil)
|
_, _ = e.discardPrint(nil)
|
||||||
_ = e.waitRenders(context.Background())
|
_ = e.waitRenders(context.Background())
|
||||||
e.renderIdle = nil
|
e.renderIdle = nil
|
||||||
@@ -369,7 +379,7 @@ func (e *NativeEngine) Close() error {
|
|||||||
if e.pendingImport != nil {
|
if e.pendingImport != nil {
|
||||||
err = errors.Join(err, e.pendingImport.Close())
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,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,
|
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...),
|
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 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
|
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
|
||||||
@@ -955,7 +965,7 @@ func (c *nativeCall) prepare(name string, args []any) ([]any, error) {
|
|||||||
return append(args, checkpoint), nil
|
return append(args, checkpoint), nil
|
||||||
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
|
case "ofdgoImportPages", "ofdgoImportDocuments", "ofdgoImportImages":
|
||||||
return append(args, c.progress("import", "", true)), nil
|
return append(args, c.progress("import", "", true)), nil
|
||||||
case "ofdgoConvertPDF":
|
case "ofdgoOpenPDF":
|
||||||
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
|
return append(args, c.progress("conversion", "", true), nativeCallback(c.font)), nil
|
||||||
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
|
case "ofdgoExportImages", "ofdgoExportPage", "ofdgoExportDocument", "ofdgoExportAttachment", "ofdgoExportResource", "ofdgoExportMedia", "ofdgoExportVersion", "ofdgoSaveDocument", "ofdgoSaveSigned":
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
|
|||||||
@@ -68,22 +68,48 @@ func (w *wasmEngines) open(args []js.Value) (any, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// call 按会话标识分派业务命令,不提供默认会话
|
// call 按会话标识分派业务命令,统一绑定本次调用的取消上下文
|
||||||
// 入参: args 会话标识、命令及业务参数
|
// 入参: args 会话标识、命令、取消检查点及业务参数
|
||||||
// 返回: any 业务结果, error 调用错误
|
// 返回: any 业务结果, error 调用错误
|
||||||
func (w *wasmEngines) call(args []js.Value) (any, error) {
|
func (w *wasmEngines) call(args []js.Value) (any, error) {
|
||||||
if len(args) < 2 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
|
if len(args) < 3 || args[0].Type() != js.TypeString || args[1].Type() != js.TypeString {
|
||||||
return nil, fmt.Errorf("missing engine ID or operation")
|
return nil, fmt.Errorf("missing engine ID or operation")
|
||||||
}
|
}
|
||||||
e := w.engines[args[0].String()]
|
e := w.engines[args[0].String()]
|
||||||
if e == nil {
|
if e == nil {
|
||||||
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
|
return nil, fmt.Errorf("unknown engine: %s", args[0].String())
|
||||||
}
|
}
|
||||||
fn := e.businessCommands()[args[1].String()]
|
name := args[1].String()
|
||||||
|
fn := e.businessCommands()[name]
|
||||||
if fn == nil {
|
if fn == nil {
|
||||||
return nil, fmt.Errorf("unknown operation: %s", args[1].String())
|
return nil, fmt.Errorf("unknown operation: %s", name)
|
||||||
}
|
}
|
||||||
return fn(args[2:])
|
if err := e.checkDocument(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.ctx = context.Background()
|
||||||
|
if args[2].Type() == js.TypeFunction {
|
||||||
|
e.ctx = e.newOutputContext(args[2])
|
||||||
|
}
|
||||||
|
ctx := e.ctx
|
||||||
|
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = ctx
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if e.ctx == ctx {
|
||||||
|
e.ctx = nil
|
||||||
|
}
|
||||||
|
if name != "ofdgoSaveSnapshot" && e.currentSession != nil {
|
||||||
|
e.currentSession.Renderer.OutputContext = context.Background()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := e.preparePDF(name, args[3:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fn(args[3:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
|
// close 释放会话的文档、编辑器、导入及剪贴板,不关闭共享资源
|
||||||
@@ -100,6 +126,7 @@ func (w *wasmEngines) close(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
delete(w.engines, id)
|
delete(w.engines, id)
|
||||||
_, _ = e.discardPrint(nil)
|
_, _ = e.discardPrint(nil)
|
||||||
|
e.clearPDF()
|
||||||
e.clearImport()
|
e.clearImport()
|
||||||
err := e.currentSession.Close()
|
err := e.currentSession.Close()
|
||||||
*e = businessEngine{}
|
*e = businessEngine{}
|
||||||
|
|||||||
@@ -1049,7 +1049,7 @@ func (h *Host) readFonts() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, face := range faces {
|
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...) {
|
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"] == "" {
|
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
||||||
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type outputOptimization struct {
|
|||||||
protected bool
|
protected bool
|
||||||
completed int
|
completed int
|
||||||
deflater *flate.Writer
|
deflater *flate.Writer
|
||||||
|
resources *editorResourceSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputCompressionBuffer 限制候选ZIP编码,不能缩小时终止本次压缩
|
// outputCompressionBuffer 限制候选ZIP编码,不能缩小时终止本次压缩
|
||||||
|
|||||||
+63
-50
@@ -46,38 +46,41 @@ const editorPathValidationLimit = 1024
|
|||||||
// 阶段可重复,准备完成不代表保存成功
|
// 阶段可重复,准备完成不代表保存成功
|
||||||
// 回调不可重入修改编辑器,可返回context.Canceled等调用方停止原因
|
// 回调不可重入修改编辑器,可返回context.Canceled等调用方停止原因
|
||||||
type Editor struct {
|
type Editor struct {
|
||||||
OnWriteProgress func(stage string, completed, total int) error
|
OnWriteProgress func(stage string, completed, total int) error
|
||||||
Info DocInfo
|
Info DocInfo
|
||||||
pages []PageContent
|
pages []PageContent
|
||||||
resources []editorResource
|
resources []editorResource
|
||||||
runtimeResources *Resources
|
runtimeResources *Resources
|
||||||
fonts map[string]*FontResource
|
fonts map[string]*FontResource
|
||||||
fontRegistration editorFontRegistration
|
fontRegistration editorFontRegistration
|
||||||
fontDirs []string
|
fontDirs []string
|
||||||
fontFS []fs.FS
|
fontFS []fs.FS
|
||||||
fontRenderer *Renderer
|
fontRenderer *Renderer
|
||||||
fontSourcesCache *fontSourceCache
|
fontSourcesCache *fontSourceCache
|
||||||
fontMetrics map[string]FontMetrics
|
fontMetrics map[string]FontMetrics
|
||||||
backends RenderBackends
|
backends RenderBackends
|
||||||
images map[string]image.Point
|
images map[string]image.Point
|
||||||
resourceID map[editorResourceKey]string
|
resourceID map[editorResourceKey]string
|
||||||
maxID int
|
maxID int
|
||||||
history []editorChange
|
history []editorChange
|
||||||
historyIndex int
|
historyIndex int
|
||||||
historyLimit int
|
historyLimit int
|
||||||
revision uint64
|
revision uint64
|
||||||
serial uint64
|
serial uint64
|
||||||
source *editorSource
|
source *editorSource
|
||||||
origins map[string]*editorObjectOrigin
|
origins map[string]*editorObjectOrigin
|
||||||
removedPages map[string]bool
|
removedPages map[string]bool
|
||||||
structureRemaps map[string][]map[string]string
|
structureRemaps map[string][]map[string]string
|
||||||
outlines []byte
|
outlines []byte
|
||||||
encryption *encryptionState
|
encryption *encryptionState
|
||||||
output *outputOptimization
|
output *outputOptimization
|
||||||
saveTarget Output
|
saveTarget Output
|
||||||
snapshotArchive *editorSnapshotArchive
|
snapshotArchive *editorSnapshotArchive
|
||||||
validatedPaths *renderCache[string, bool]
|
validatedPaths *renderCache[string, bool]
|
||||||
richIndex *richTextIndex
|
changes EditorChanges
|
||||||
|
transactionChanges *EditorChanges
|
||||||
|
objectIndex *editorObjectIndex
|
||||||
|
richIndex *richTextIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorResource 文档内嵌资源
|
// editorResource 文档内嵌资源
|
||||||
@@ -223,7 +226,14 @@ func (e *Editor) SetInfo(info DocInfo) {
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
func (e *Editor) Page(index int) (*PageContent, error) {
|
func (e *Editor) Page(index int) (*PageContent, error) {
|
||||||
page, err := e.page(index)
|
return e.PageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageContext 获取独立页面副本,读取、解析及整理对象时响应取消
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *PageContent 页面内容, error 读取或取消错误
|
||||||
|
func (e *Editor) PageContext(ctx context.Context, index int) (*PageContent, error) {
|
||||||
|
page, err := e.pageContext(ctx, index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -233,9 +243,11 @@ func (e *Editor) Page(index int) (*PageContent, error) {
|
|||||||
layer := &result.Content.Layer[i]
|
layer := &result.Content.Layer[i]
|
||||||
layer.TextObject, layer.PathObject, layer.ImageObject, layer.CompositeGraphicUnit = nil, nil, nil, nil
|
layer.TextObject, layer.PathObject, layer.ImageObject, layer.CompositeGraphicUnit = nil, nil, nil, nil
|
||||||
for _, object := range layer.Objects {
|
for _, object := range layer.Objects {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
object.TextObject.layout = nil
|
|
||||||
layer.TextObject = append(layer.TextObject, object.TextObject)
|
layer.TextObject = append(layer.TextObject, object.TextObject)
|
||||||
case "PathObject":
|
case "PathObject":
|
||||||
layer.PathObject = append(layer.PathObject, object.PathObject)
|
layer.PathObject = append(layer.PathObject, object.PathObject)
|
||||||
@@ -618,7 +630,9 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
|
|||||||
}
|
}
|
||||||
object, err := cloneEditorObject(layer.Objects[index])
|
object, err := cloneEditorObject(layer.Objects[index])
|
||||||
object.origin = e.objectOrigin(id)
|
object.origin = e.objectOrigin(id)
|
||||||
e.attachRichTextSnapshot(page, &object)
|
if err := e.attachRichTextSnapshot(page, &object); err != nil {
|
||||||
|
return GraphicObject{}, err
|
||||||
|
}
|
||||||
return object, err
|
return object, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,13 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := LayoutText(obj, value, options, metrics); err != nil {
|
return LayoutText(obj, value, options, metrics)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if obj.layout == nil && e.objectOrigin(obj.ID) != nil {
|
|
||||||
obj.layout = &textLayout{value: obj.Text(), options: options}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
||||||
@@ -820,11 +828,21 @@ func (r editorResource) definition() string {
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
func (e *Editor) page(index int) (*PageContent, error) {
|
func (e *Editor) page(index int) (*PageContent, error) {
|
||||||
|
return e.pageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageContext 按索引加载内部页面并响应取消
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *PageContent 页面内容, error 读取或取消错误
|
||||||
|
func (e *Editor) pageContext(ctx context.Context, index int) (*PageContent, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if index < 0 || index >= len(e.pages) {
|
if index < 0 || index >= len(e.pages) {
|
||||||
return nil, fmt.Errorf("page index %d out of range", index)
|
return nil, fmt.Errorf("page index %d out of range", index)
|
||||||
}
|
}
|
||||||
if e.source != nil {
|
if e.source != nil {
|
||||||
if err := e.loadSourcePage(index); err != nil {
|
if err := e.loadSourcePageContext(ctx, index); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -872,13 +890,8 @@ func (e *Editor) findObject(page int, id string) (*Layer, int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
for i := range content.Content.Layer {
|
if position, ok := e.objectPositions(content)[id]; ok {
|
||||||
layer := &content.Content.Layer[i]
|
return &content.Content.Layer[position.layer], position.index, nil
|
||||||
for index, object := range layer.Objects {
|
|
||||||
if editorObjectID(object) == id && id != "" {
|
|
||||||
return layer, index, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, 0, fmt.Errorf("object %q not found on page %d", id, page)
|
return nil, 0, fmt.Errorf("object %q not found on page %d", id, page)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (e *Editor) SetPageActions(index int, actions []Action) error {
|
|||||||
}
|
}
|
||||||
before, after := cloneEditorData(page.Actions), cloneEditorData(actions)
|
before, after := cloneEditorData(page.Actions), cloneEditorData(actions)
|
||||||
page.Actions = cloneEditorData(after)
|
page.Actions = cloneEditorData(after)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(index); change != nil {
|
||||||
change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) }
|
change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) }
|
||||||
change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) }
|
change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) }
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -32,6 +32,7 @@ type AnnotationSelection struct {
|
|||||||
data [][]byte
|
data [][]byte
|
||||||
resources []string
|
resources []string
|
||||||
states map[string]map[string]editorCompositeState
|
states map[string]map[string]editorCompositeState
|
||||||
|
richText map[int]*richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
|
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
|
||||||
@@ -317,7 +318,7 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
seen := make(map[string]bool)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
key := editorResourceID(id)
|
key := editorResourceID(id)
|
||||||
@@ -336,6 +337,17 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
|||||||
if !editorXMLCopyable(root) {
|
if !editorXMLCopyable(root) {
|
||||||
return nil, fmt.Errorf("annotation contains unsupported identifiers")
|
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.data = append(selection.data, data)
|
||||||
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
|
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
|
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
|
return nil
|
||||||
|
|||||||
+58
-24
@@ -30,6 +30,36 @@ type editorObjectPosition struct {
|
|||||||
index int
|
index int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editorObjectIndex 复用最近页面的只读对象位置,图层容器替换后重新建立
|
||||||
|
type editorObjectIndex struct {
|
||||||
|
layers []Layer
|
||||||
|
positions map[string]editorObjectPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectPositions 获取页面的对象位置索引,重复编号保留第一个匹配
|
||||||
|
// 入参: page 内部只读页面
|
||||||
|
// 返回: map[string]editorObjectPosition 只读索引
|
||||||
|
func (e *Editor) objectPositions(page *PageContent) map[string]editorObjectPosition {
|
||||||
|
layers := page.Content.Layer
|
||||||
|
if len(layers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cached := e.objectIndex; cached != nil && len(cached.layers) == len(layers) && &cached.layers[0] == &layers[0] {
|
||||||
|
return cached.positions
|
||||||
|
}
|
||||||
|
positions := make(map[string]editorObjectPosition)
|
||||||
|
for i, layer := range layers {
|
||||||
|
for j, object := range layer.Objects {
|
||||||
|
id := editorObjectID(object)
|
||||||
|
if _, exists := positions[id]; id != "" && !exists {
|
||||||
|
positions[id] = editorObjectPosition{i, j}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.objectIndex = &editorObjectIndex{layers: layers, positions: positions}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象
|
// ObjectPosition 对象在图层或页块直接成员中的位置,Index从0开始,Count不含嵌套页块的对象
|
||||||
type ObjectPosition struct {
|
type ObjectPosition struct {
|
||||||
Layer string
|
Layer string
|
||||||
@@ -66,7 +96,9 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
objects[i].origin = e.objectOrigin(editorObjectID(object))
|
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
|
return objects, nil
|
||||||
}
|
}
|
||||||
@@ -105,7 +137,11 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -709,7 +745,7 @@ func (e *Editor) replaceLayers(page int, layers []Layer) {
|
|||||||
func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
||||||
if e.historyLimit == 0 {
|
if e.historyLimit == 0 {
|
||||||
e.pages[page].Content.Layer = after
|
e.pages[page].Content.Layer = after
|
||||||
e.recordChange()
|
e.recordChange(page)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
before := copyEditorPage(e.pages[page])
|
before := copyEditorPage(e.pages[page])
|
||||||
@@ -738,7 +774,7 @@ func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
|||||||
e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
|
e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
|
||||||
}
|
}
|
||||||
apply(e, after)
|
apply(e, after)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before.Content.Layer) }
|
change.undo = func(e *Editor) { apply(e, before.Content.Layer) }
|
||||||
change.redo = func(e *Editor) { apply(e, after) }
|
change.redo = func(e *Editor) { apply(e, after) }
|
||||||
}
|
}
|
||||||
@@ -835,6 +871,15 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
apply := func(e *Editor, objects []GraphicObject, origins map[string]*editorObjectOrigin) {
|
apply := func(e *Editor, objects []GraphicObject, origins map[string]*editorObjectOrigin) {
|
||||||
|
layers := slices.Clone(e.pages[page].Content.Layer)
|
||||||
|
cloned := make(map[int]bool)
|
||||||
|
for _, index := range indexes {
|
||||||
|
if !cloned[index.layer] {
|
||||||
|
layers[index.layer].Objects = slices.Clone(layers[index.layer].Objects)
|
||||||
|
cloned[index.layer] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.pages[page].Content.Layer = layers
|
||||||
for i, index := range indexes {
|
for i, index := range indexes {
|
||||||
e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i]
|
e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i]
|
||||||
}
|
}
|
||||||
@@ -843,7 +888,7 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply(e, after, afterOrigins)
|
apply(e, after, afterOrigins)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before, beforeOrigins) }
|
change.undo = func(e *Editor) { apply(e, before, beforeOrigins) }
|
||||||
change.redo = func(e *Editor) { apply(e, after, afterOrigins) }
|
change.redo = func(e *Editor) { apply(e, after, afterOrigins) }
|
||||||
}
|
}
|
||||||
@@ -891,30 +936,19 @@ func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []edi
|
|||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return objects, indexes, nil
|
return objects, indexes, nil
|
||||||
}
|
}
|
||||||
selected := make(map[string]int, len(ids))
|
positions := e.objectPositions(content)
|
||||||
|
selected := make(map[string]bool, len(ids))
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
if _, exists := selected[id]; exists {
|
if selected[id] {
|
||||||
return nil, nil, fmt.Errorf("duplicate object %q", id)
|
return nil, nil, fmt.Errorf("duplicate object %q", id)
|
||||||
}
|
}
|
||||||
selected[id] = i
|
selected[id] = true
|
||||||
}
|
position, ok := positions[id]
|
||||||
for i, layer := range content.Content.Layer {
|
if !ok {
|
||||||
for j, object := range layer.Objects {
|
|
||||||
id := editorObjectID(object)
|
|
||||||
if index, ok := selected[id]; ok && id != "" {
|
|
||||||
objects[index] = object
|
|
||||||
indexes[index] = editorObjectPosition{i, j}
|
|
||||||
delete(selected, id)
|
|
||||||
if len(selected) == 0 {
|
|
||||||
return objects, indexes, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
if _, missing := selected[id]; missing {
|
|
||||||
return nil, nil, fmt.Errorf("object %q not found on page %d", id, page)
|
return nil, nil, fmt.Errorf("object %q not found on page %d", id, page)
|
||||||
}
|
}
|
||||||
|
indexes[i] = position
|
||||||
|
objects[i] = content.Content.Layer[position.layer].Objects[position.index]
|
||||||
}
|
}
|
||||||
return objects, indexes, nil
|
return objects, indexes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,7 +334,9 @@ func (n *editorCompositeNode) setStates(states map[string]editorCompositeState)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(n.node)
|
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 替换当前对象的会话记录并计入本次事务
|
// setState 替换当前对象的会话记录并计入本次事务
|
||||||
@@ -418,6 +420,7 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
root.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, attrs: []xml.Attr{{Name: xml.Name{Local: "ID"}, Value: layer.ID}, {Name: xml.Name{Local: "DrawParam"}, Value: layer.DrawParam}}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
||||||
root.setStates(object.CompositeGraphicUnit.states)
|
root.setStates(object.CompositeGraphicUnit.states)
|
||||||
reader, err := e.reader(nil)
|
reader, err := e.reader(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -848,6 +851,7 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render
|
|||||||
next.setStates(root.states)
|
next.setStates(root.states)
|
||||||
next.object.CompositeGraphicUnit.states = next.states
|
next.object.CompositeGraphicUnit.states = next.states
|
||||||
origin := e.objectOrigin(path.ID)
|
origin := e.objectOrigin(path.ID)
|
||||||
|
next.node.parent = root.node.parent
|
||||||
updated := &editorObjectOrigin{data: data, node: next.node, object: next.object}
|
updated := &editorObjectOrigin{data: data, node: next.node, object: next.object}
|
||||||
if origin != nil {
|
if origin != nil {
|
||||||
next.node.parent = origin.node.parent
|
next.node.parent = origin.node.parent
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ package ofdgo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
|
basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源
|
// CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源
|
||||||
@@ -36,6 +36,7 @@ type CompositeSelection struct {
|
|||||||
containers []int
|
containers []int
|
||||||
nodes []*editorCompositeNode
|
nodes []*editorCompositeNode
|
||||||
source *editorSourcePage
|
source *editorSourcePage
|
||||||
|
richText map[int]*richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
|
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
|
||||||
@@ -73,9 +74,11 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
|||||||
slices.Sort(indexes)
|
slices.Sort(indexes)
|
||||||
var owner *editorCompositeNode
|
var owner *editorCompositeNode
|
||||||
var container *editorXML
|
var container *editorXML
|
||||||
selection := &CompositeSelection{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 path.Annotation == "" {
|
||||||
selection.source = e.objectOrigin(path.ID).page
|
if origin := e.objectOrigin(path.ID); origin != nil {
|
||||||
|
selection.source = origin.page
|
||||||
|
}
|
||||||
} else if e.source != nil {
|
} else if e.source != nil {
|
||||||
selection.source = e.source.pages[e.pages[page].ID]
|
selection.source = e.source.pages[e.pages[page].ID]
|
||||||
}
|
}
|
||||||
@@ -95,6 +98,13 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
|||||||
copy := *node
|
copy := *node
|
||||||
copy.owner, copy.span = nil, nil
|
copy.owner, copy.span = nil, nil
|
||||||
selection.nodes = append(selection.nodes, ©)
|
selection.nodes = append(selection.nodes, ©)
|
||||||
|
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)
|
scope := root.loadedScope(path.Children)
|
||||||
for scope != owner {
|
for scope != owner {
|
||||||
@@ -134,62 +144,68 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
var result []int
|
var result []int
|
||||||
err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error {
|
err := e.Transaction(func(e *Editor) error {
|
||||||
scope := root.loadedScope(path.Children)
|
err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error {
|
||||||
owner := scope
|
scope := root.loadedScope(path.Children)
|
||||||
for i := 0; i < selection.references; i++ {
|
owner := scope
|
||||||
owner = owner.ref
|
for i := 0; i < selection.references; i++ {
|
||||||
if owner == nil {
|
owner = owner.ref
|
||||||
return fmt.Errorf("composite resource container no longer exists")
|
if owner == nil {
|
||||||
|
return fmt.Errorf("composite resource container no longer exists")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
container := owner.node
|
||||||
container := owner.node
|
for _, index := range selection.containers {
|
||||||
for _, index := range selection.containers {
|
containers := compositeContainers(container)
|
||||||
containers := compositeContainers(container)
|
if index >= len(containers) {
|
||||||
if index >= len(containers) {
|
return fmt.Errorf("composite page block no longer exists")
|
||||||
return fmt.Errorf("composite page block no longer exists")
|
}
|
||||||
|
container = containers[index]
|
||||||
}
|
}
|
||||||
container = containers[index]
|
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
||||||
}
|
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
||||||
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
nodes := make([]*editorCompositeNode, len(selection.nodes))
|
||||||
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
for i, node := range selection.nodes {
|
||||||
nodes := make([]*editorCompositeNode, len(selection.nodes))
|
copy := *node
|
||||||
for i, node := range selection.nodes {
|
copy.parent, copy.boundaryInCTM = parent, owner.boundaryInCTM
|
||||||
copy := *node
|
nodes[i] = ©
|
||||||
copy.parent, copy.boundaryInCTM = parent, owner.boundaryInCTM
|
}
|
||||||
nodes[i] = ©
|
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
||||||
}
|
if err != nil {
|
||||||
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
return err
|
||||||
|
}
|
||||||
|
maps.Copy(owner.states, states)
|
||||||
|
next, reached := 0, false
|
||||||
|
var count func(*editorCompositeNode)
|
||||||
|
count = func(node *editorCompositeNode) {
|
||||||
|
if node.ref != nil {
|
||||||
|
count(node.ref)
|
||||||
|
}
|
||||||
|
if reached {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, child := range node.children {
|
||||||
|
if node != owner || child.span.start < container.close {
|
||||||
|
next++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reached = node == owner
|
||||||
|
}
|
||||||
|
count(scope)
|
||||||
|
patch := editorXMLPatch{container.close, container.close, data}
|
||||||
|
if container.open == container.end {
|
||||||
|
patch = editorXMLContent(owner.data, container, data)
|
||||||
|
}
|
||||||
|
owner.patches = append(owner.patches, patch)
|
||||||
|
for i := range nodes {
|
||||||
|
result = append(result, next+i)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
maps.Copy(owner.states, states)
|
return e.putCompositeRichTextCopies(page, path, result, selection.richText)
|
||||||
next, reached := 0, false
|
|
||||||
var count func(*editorCompositeNode)
|
|
||||||
count = func(node *editorCompositeNode) {
|
|
||||||
if node.ref != nil {
|
|
||||||
count(node.ref)
|
|
||||||
}
|
|
||||||
if reached {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, child := range node.children {
|
|
||||||
if node != owner || child.span.start < container.close {
|
|
||||||
next++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reached = node == owner
|
|
||||||
}
|
|
||||||
count(scope)
|
|
||||||
patch := editorXMLPatch{container.close, container.close, data}
|
|
||||||
if container.open == container.end {
|
|
||||||
patch = editorXMLContent(owner.data, container, data)
|
|
||||||
}
|
|
||||||
owner.patches = append(owner.patches, patch)
|
|
||||||
for i := range nodes {
|
|
||||||
result = append(result, next+i)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -226,7 +242,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
|
|||||||
renderer := e.newRenderer(reader)
|
renderer := e.newRenderer(reader)
|
||||||
var objects []GraphicObject
|
var objects []GraphicObject
|
||||||
var remaps []map[string]string
|
var remaps []map[string]string
|
||||||
for _, source := range selection.nodes {
|
for i, source := range selection.nodes {
|
||||||
copy, err := newEditorCompositeNode(source.data)
|
copy, err := newEditorCompositeNode(source.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -322,13 +338,16 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object.state = copy.states[editorObjectID(copy.object)]
|
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
|
copy.object.TextObject.layout = object.TextObject.layout
|
||||||
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
|
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
|
||||||
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
|
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
|
||||||
copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.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}
|
object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object}
|
||||||
|
object.richText = selection.richText[i]
|
||||||
objects = append(objects, object)
|
objects = append(objects, object)
|
||||||
remaps = append(remaps, copy.remaps...)
|
remaps = append(remaps, copy.remaps...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
||||||
member.object.state = states[editorObjectID(member.object)]
|
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.CompositeGraphicUnit.states = states
|
||||||
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
|
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
|
||||||
objects[i] = member.object
|
objects[i] = member.object
|
||||||
|
|||||||
@@ -40,6 +40,29 @@ func (e *Editor) AddCompositeObject(page int, path ObjectPath, object GraphicObj
|
|||||||
// 入参: page 页面索引, path 父路径, objects 当前文档快照或基本对象, dx 页面横向位移, dy 页面纵向位移
|
// 入参: page 页面索引, path 父路径, objects 当前文档快照或基本对象, dx 页面横向位移, dy 页面纵向位移
|
||||||
// 返回: []int 新成员序号, error 错误信息
|
// 返回: []int 新成员序号, error 错误信息
|
||||||
func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
||||||
|
metadata, err := e.copiedRichTextMetadata(objects)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(metadata) == 0 {
|
||||||
|
return e.copyObjectsToComposite(page, path, objects, dx, dy)
|
||||||
|
}
|
||||||
|
var indexes []int
|
||||||
|
err = e.Transaction(func(e *Editor) error {
|
||||||
|
var err error
|
||||||
|
indexes, err = e.copyObjectsToComposite(page, path, objects, dx, dy)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextCopies(page, path, indexes, metadata)
|
||||||
|
})
|
||||||
|
return indexes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyObjectsToComposite 插入对象原文,由调用方同步富文本语义
|
||||||
|
// 入参: page 页面索引, path 父路径, objects 对象快照, dx 横向位移, dy 纵向位移
|
||||||
|
// 返回: []int 成员序号, error 校验或提交错误
|
||||||
|
func (e *Editor) copyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) {
|
||||||
if !finite(dx) || !finite(dy) {
|
if !finite(dx) || !finite(dy) {
|
||||||
return nil, fmt.Errorf("copy requires finite offsets")
|
return nil, fmt.Errorf("copy requires finite offsets")
|
||||||
}
|
}
|
||||||
@@ -49,6 +72,9 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
owner := root.loadedScope(path.Children)
|
owner := root.loadedScope(path.Children)
|
||||||
|
if owner.ref != nil {
|
||||||
|
owner = owner.ref
|
||||||
|
}
|
||||||
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary)
|
||||||
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM))
|
||||||
inverse, ok := parent.Invert()
|
inverse, ok := parent.Invert()
|
||||||
|
|||||||
@@ -45,31 +45,47 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
|||||||
return nil, fmt.Errorf("copy requires finite offsets")
|
return nil, fmt.Errorf("copy requires finite offsets")
|
||||||
}
|
}
|
||||||
var result []int
|
var result []int
|
||||||
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
metadata := make(map[int]*richTextSnapshot)
|
||||||
container := nodes[0].span.parent
|
err := e.Transaction(func(e *Editor) error {
|
||||||
for i, node := range nodes {
|
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||||
if !members[i].Capabilities.Copy || node.span.parent != container {
|
container := nodes[0].span.parent
|
||||||
return fmt.Errorf("copy requires editable members in the same container")
|
for i, node := range nodes {
|
||||||
|
if !members[i].Capabilities.Copy || node.span.parent != container {
|
||||||
|
return fmt.Errorf("copy requires editable members in the same container")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
|
||||||
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
|
for i, node := range nodes {
|
||||||
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owner := nodes[0].owner
|
||||||
|
maps.Copy(owner.states, states)
|
||||||
|
owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data})
|
||||||
|
next := 0
|
||||||
|
for _, member := range owner.children {
|
||||||
|
if container.start < member.span.start && member.span.end <= container.close {
|
||||||
|
next = member.index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range nodes {
|
||||||
|
result = append(result, next+i)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
owner := nodes[0].owner
|
return e.putCompositeRichTextCopies(page, path, result, metadata)
|
||||||
maps.Copy(owner.states, states)
|
|
||||||
owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data})
|
|
||||||
next := 0
|
|
||||||
for _, member := range owner.children {
|
|
||||||
if container.start < member.span.start && member.span.end <= container.close {
|
|
||||||
next = member.index + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range nodes {
|
|
||||||
result = append(result, next+i)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排
|
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按排版选项重排
|
||||||
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
||||||
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
||||||
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
// 入参: 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)
|
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位
|
// StyleCompositeText 批量修改内部文字字体、字号或颜色,已知段落重排,原文保留定位
|
||||||
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
||||||
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -38,7 +38,7 @@ func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
||||||
// 段落选项仅保留在编辑会话中,撤销重做随对象恢复,保存为标准TextCode定位
|
// 段落选项随标准TextCode定位保存,撤销重做及重新打开时恢复
|
||||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) 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)
|
object := cloneEditorData(node.object)
|
||||||
text := cloneEditorData(member.Object.TextObject)
|
text := cloneEditorData(member.Object.TextObject)
|
||||||
state := node.states[editorObjectID(node.object)]
|
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()
|
content, layout := text.TextLayout()
|
||||||
original := content
|
original := content
|
||||||
if style.Font != "" {
|
if style.Font != "" {
|
||||||
@@ -121,7 +129,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.TextCode = text.TextCode
|
object.TextObject.TextCode = text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
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 {
|
if style.Size != 0 {
|
||||||
text.Size = style.Size
|
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.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
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 style.Font != "" && style.Font != object.TextObject.Font {
|
||||||
if err := e.prepareText(&text); err != nil {
|
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.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor)
|
||||||
}
|
}
|
||||||
|
object.TextObject.layout = state.layout
|
||||||
if err := node.update(object); err != nil {
|
if err := node.update(object); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -241,6 +242,22 @@ func (e *Editor) copiedLayerStyle(object GraphicObject) string {
|
|||||||
// 入参: value 原值
|
// 入参: value 原值
|
||||||
// 返回: T 独立副本
|
// 返回: T 独立副本
|
||||||
func cloneEditorData[T any](value T) T {
|
func cloneEditorData[T any](value T) T {
|
||||||
|
switch source := any(value).(type) {
|
||||||
|
case GraphicObject:
|
||||||
|
return any(copyEditorGraphicObject(source)).(T)
|
||||||
|
case TextObject:
|
||||||
|
return any(copyEditorTextObject(source)).(T)
|
||||||
|
case PathObject:
|
||||||
|
return any(copyEditorPathObject(source)).(T)
|
||||||
|
case ImageObject:
|
||||||
|
return any(copyEditorImageObject(source)).(T)
|
||||||
|
case CompositeGraphicUnit:
|
||||||
|
return any(copyEditorCompositeGraphicUnit(source)).(T)
|
||||||
|
case PageContent:
|
||||||
|
return any(copyEditorPageContent(source)).(T)
|
||||||
|
case Layer:
|
||||||
|
return any(copyEditorLayer(source)).(T)
|
||||||
|
}
|
||||||
source := reflect.ValueOf(value)
|
source := reflect.ValueOf(value)
|
||||||
if !source.IsValid() {
|
if !source.IsValid() {
|
||||||
return value
|
return value
|
||||||
@@ -336,3 +353,174 @@ func editorCloneFields(kind reflect.Type) []int {
|
|||||||
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
|
actual, _ := editorClonePlans.LoadOrStore(kind, fields)
|
||||||
return actual.([]int)
|
return actual.([]int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyEditorGraphicObject 复制GraphicObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: GraphicObject 独立副本
|
||||||
|
func copyEditorGraphicObject(value GraphicObject) GraphicObject {
|
||||||
|
value.TextObject = copyEditorTextObject(value.TextObject)
|
||||||
|
value.PathObject = copyEditorPathObject(value.PathObject)
|
||||||
|
value.ImageObject = copyEditorImageObject(value.ImageObject)
|
||||||
|
value.CompositeGraphicUnit = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorTextObject 复制TextObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: TextObject 独立副本
|
||||||
|
func copyEditorTextObject(value TextObject) TextObject {
|
||||||
|
if value.DashOffset != nil {
|
||||||
|
copied := *value.DashOffset
|
||||||
|
value.DashOffset = &copied
|
||||||
|
}
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
if value.Fill != nil {
|
||||||
|
copied := *value.Fill
|
||||||
|
value.Fill = &copied
|
||||||
|
}
|
||||||
|
if value.Stroke != nil {
|
||||||
|
copied := *value.Stroke
|
||||||
|
value.Stroke = &copied
|
||||||
|
}
|
||||||
|
value.StrokeColor = cloneEditorData(value.StrokeColor)
|
||||||
|
value.FillColor = cloneEditorData(value.FillColor)
|
||||||
|
value.CGTransform = slices.Clone(value.CGTransform)
|
||||||
|
value.TextCode = slices.Clone(value.TextCode)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorPathObject 复制PathObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: PathObject 独立副本
|
||||||
|
func copyEditorPathObject(value PathObject) PathObject {
|
||||||
|
if value.DashOffset != nil {
|
||||||
|
copied := *value.DashOffset
|
||||||
|
value.DashOffset = &copied
|
||||||
|
}
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
if value.Stroke != nil {
|
||||||
|
copied := *value.Stroke
|
||||||
|
value.Stroke = &copied
|
||||||
|
}
|
||||||
|
if value.Fill != nil {
|
||||||
|
copied := *value.Fill
|
||||||
|
value.Fill = &copied
|
||||||
|
}
|
||||||
|
value.StrokeColor = cloneEditorData(value.StrokeColor)
|
||||||
|
value.FillColor = cloneEditorData(value.FillColor)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorImageObject 复制ImageObject的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: ImageObject 独立副本
|
||||||
|
func copyEditorImageObject(value ImageObject) ImageObject {
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
value.Border = cloneEditorData(value.Border)
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorCompositeGraphicUnit 复制CompositeGraphicUnit的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: CompositeGraphicUnit 独立副本
|
||||||
|
func copyEditorCompositeGraphicUnit(value CompositeGraphicUnit) CompositeGraphicUnit {
|
||||||
|
if value.Alpha != nil {
|
||||||
|
copied := *value.Alpha
|
||||||
|
value.Alpha = &copied
|
||||||
|
}
|
||||||
|
if value.Visible != nil {
|
||||||
|
copied := *value.Visible
|
||||||
|
value.Visible = &copied
|
||||||
|
}
|
||||||
|
value.Objects = slices.Clone(value.Objects)
|
||||||
|
for i := range value.Objects {
|
||||||
|
value.Objects[i] = copyEditorGraphicObject(value.Objects[i])
|
||||||
|
}
|
||||||
|
value.TextObject = slices.Clone(value.TextObject)
|
||||||
|
for i := range value.TextObject {
|
||||||
|
value.TextObject[i] = copyEditorTextObject(value.TextObject[i])
|
||||||
|
}
|
||||||
|
value.PathObject = slices.Clone(value.PathObject)
|
||||||
|
for i := range value.PathObject {
|
||||||
|
value.PathObject[i] = copyEditorPathObject(value.PathObject[i])
|
||||||
|
}
|
||||||
|
value.ImageObject = slices.Clone(value.ImageObject)
|
||||||
|
for i := range value.ImageObject {
|
||||||
|
value.ImageObject[i] = copyEditorImageObject(value.ImageObject[i])
|
||||||
|
}
|
||||||
|
value.CompositeGraphicUnit = slices.Clone(value.CompositeGraphicUnit)
|
||||||
|
for i := range value.CompositeGraphicUnit {
|
||||||
|
value.CompositeGraphicUnit[i] = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit[i])
|
||||||
|
}
|
||||||
|
value.Clips = cloneEditorData(value.Clips)
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorPageContent 复制PageContent的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: PageContent 独立副本
|
||||||
|
func copyEditorPageContent(value PageContent) PageContent {
|
||||||
|
value.Template = slices.Clone(value.Template)
|
||||||
|
value.PageRes = slices.Clone(value.PageRes)
|
||||||
|
value.Content.Layer = slices.Clone(value.Content.Layer)
|
||||||
|
for i := range value.Content.Layer {
|
||||||
|
value.Content.Layer[i] = copyEditorLayer(value.Content.Layer[i])
|
||||||
|
}
|
||||||
|
value.Actions = cloneEditorData(value.Actions)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyEditorLayer 复制Layer的可变字段,保留内部只读状态
|
||||||
|
// 入参: value 原值
|
||||||
|
// 返回: Layer 独立副本
|
||||||
|
func copyEditorLayer(value Layer) Layer {
|
||||||
|
value.Objects = slices.Clone(value.Objects)
|
||||||
|
for i := range value.Objects {
|
||||||
|
value.Objects[i] = copyEditorGraphicObject(value.Objects[i])
|
||||||
|
}
|
||||||
|
value.TextObject = slices.Clone(value.TextObject)
|
||||||
|
for i := range value.TextObject {
|
||||||
|
value.TextObject[i] = copyEditorTextObject(value.TextObject[i])
|
||||||
|
}
|
||||||
|
value.PathObject = slices.Clone(value.PathObject)
|
||||||
|
for i := range value.PathObject {
|
||||||
|
value.PathObject[i] = copyEditorPathObject(value.PathObject[i])
|
||||||
|
}
|
||||||
|
value.ImageObject = slices.Clone(value.ImageObject)
|
||||||
|
for i := range value.ImageObject {
|
||||||
|
value.ImageObject[i] = copyEditorImageObject(value.ImageObject[i])
|
||||||
|
}
|
||||||
|
value.CompositeGraphicUnit = slices.Clone(value.CompositeGraphicUnit)
|
||||||
|
for i := range value.CompositeGraphicUnit {
|
||||||
|
value.CompositeGraphicUnit[i] = copyEditorCompositeGraphicUnit(value.CompositeGraphicUnit[i])
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
+23
-13
@@ -34,8 +34,9 @@ type editorFontUsage struct {
|
|||||||
unsafe bool
|
unsafe bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合
|
// editorFontSubset 保存单个新增字体及其快照最近一次裁剪结果,不保留历史字形集合
|
||||||
type editorFontSubset struct {
|
type editorFontSubset struct {
|
||||||
|
source [32]byte
|
||||||
glyphs []uint16
|
glyphs []uint16
|
||||||
data []byte
|
data []byte
|
||||||
mapped bool
|
mapped bool
|
||||||
@@ -317,13 +318,13 @@ func (e *Editor) subsetSnapshotFonts(ctx context.Context, progress editorProgres
|
|||||||
if e.source == nil || e.backends.FontResources == nil {
|
if e.source == nil || e.backends.FontResources == nil {
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
}
|
}
|
||||||
candidates := make(map[string]bool)
|
candidates := make(map[string]int)
|
||||||
for _, resource := range e.resources {
|
for i, resource := range e.resources {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if resource.font != nil && resource.font.FontFile != "" && e.source.reader.fileNames[resource.name] {
|
if resource.font != nil && resource.font.FontFile != "" && e.source.reader.fileNames[resource.name] {
|
||||||
candidates[resource.name] = true
|
candidates[resource.name] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
@@ -399,15 +400,24 @@ func (e *Editor) subsetSnapshotFonts(ctx context.Context, progress editorProgres
|
|||||||
if font.unsafe {
|
if font.unsafe {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
subset, err := e.backends.FontResources.SubsetFont(data, slices.Sorted(maps.Keys(glyphs)), preserve)
|
ids := slices.Sorted(maps.Keys(glyphs))
|
||||||
if err != nil {
|
checksum := sha256.Sum256(data)
|
||||||
return nil, err
|
resource := &e.resources[candidates[name]]
|
||||||
|
if resource.subset == nil || resource.subset.source != checksum || resource.subset.mapped != preserve || !slices.Equal(resource.subset.glyphs, ids) {
|
||||||
|
subset, err := e.backends.FontResources.SubsetFont(data, ids, preserve)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(subset) >= len(data) {
|
||||||
|
subset = nil
|
||||||
|
}
|
||||||
|
resource.subset = &editorFontSubset{source: checksum, glyphs: ids, data: subset, mapped: preserve}
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if len(resource.subset.data) != 0 {
|
||||||
return nil, err
|
result[name] = resource.subset.data
|
||||||
}
|
|
||||||
if len(subset) != 0 && len(subset) < len(data) {
|
|
||||||
result[name] = subset
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, progress.report("fonts", len(candidates), len(candidates))
|
return result, progress.report("fonts", len(candidates), len(candidates))
|
||||||
@@ -593,7 +603,7 @@ func (e *Editor) subsetNewFonts(progress editorProgress) (map[string][]byte, err
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resource := &e.resources[i]
|
resource := &e.resources[i]
|
||||||
if resource.font == nil {
|
if resource.font == nil || e.source != nil && e.source.reader.fileNames[resource.name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usage := used[resource.font.ID]
|
usage := used[resource.font.ID]
|
||||||
|
|||||||
@@ -963,12 +963,12 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
|
|||||||
if root == "" {
|
if root == "" {
|
||||||
root = node.Name.Local
|
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
|
uncertain = true
|
||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||||
}
|
}
|
||||||
for _, attr := range node.Attr {
|
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
|
uncertain = true
|
||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
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})
|
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
|
var syntax *xml.SyntaxError
|
||||||
if uncertain || errors.As(err, &syntax) {
|
if uncertain || errors.As(err, &syntax) {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, attr := range node.attrs {
|
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
|
scan.unsafe = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) {
|
|||||||
state := object.state
|
state := object.state
|
||||||
if object.Type == "TextObject" && object.TextObject.layout != nil {
|
if object.Type == "TextObject" && object.TextObject.layout != nil {
|
||||||
state.layout = object.TextObject.layout
|
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)
|
state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64)
|
||||||
}
|
}
|
||||||
states[editorObjectID(object)] = state
|
states[editorObjectID(object)] = state
|
||||||
@@ -253,7 +256,7 @@ func (e *Editor) replaceGroup(page int, indexes []editorObjectPosition, objects
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
apply(e, after, origins)
|
apply(e, after, origins)
|
||||||
if change := e.recordChange(); change != nil {
|
if change := e.recordChange(page); change != nil {
|
||||||
change.undo = func(e *Editor) { apply(e, before, previous) }
|
change.undo = func(e *Editor) { apply(e, before, previous) }
|
||||||
change.redo = func(e *Editor) { apply(e, after, origins) }
|
change.redo = func(e *Editor) { apply(e, after, origins) }
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-15
@@ -21,10 +21,43 @@ import (
|
|||||||
|
|
||||||
// editorChange 页面或对象操作及其修订标识
|
// editorChange 页面或对象操作及其修订标识
|
||||||
type editorChange struct {
|
type editorChange struct {
|
||||||
undo func(*Editor)
|
undo func(*Editor)
|
||||||
redo func(*Editor)
|
redo func(*Editor)
|
||||||
before uint64
|
before uint64
|
||||||
after uint64
|
after uint64
|
||||||
|
changes EditorChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditorChanges 描述最近一次修改或历史回放的刷新范围
|
||||||
|
// All表示文档结构或共享资源变化;否则Pages为受影响的零基页码
|
||||||
|
// 返回值只覆盖最近一次操作,调用方应在每次操作后读取
|
||||||
|
type EditorChanges struct {
|
||||||
|
All bool `json:"all"`
|
||||||
|
Pages []int `json:"pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes 获取最近一次有效修改的独立刷新范围
|
||||||
|
// 返回: EditorChanges 变化范围;未发生修改时为空
|
||||||
|
func (e *Editor) Changes() EditorChanges {
|
||||||
|
return EditorChanges{All: e.changes.All, Pages: slices.Clone(e.changes.Pages)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge 合并同一事务中的变化范围
|
||||||
|
// 入参: change 待合并的变化
|
||||||
|
func (c *EditorChanges) merge(change EditorChanges) {
|
||||||
|
if c.All {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if change.All {
|
||||||
|
c.All = true
|
||||||
|
c.Pages = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, page := range change.Pages {
|
||||||
|
if !slices.Contains(c.Pages, page) {
|
||||||
|
c.Pages = append(c.Pages, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史
|
// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史
|
||||||
@@ -37,6 +70,8 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
before.Info = cloneEditorData(e.Info)
|
before.Info = cloneEditorData(e.Info)
|
||||||
next := e.transactionSnapshot()
|
next := e.transactionSnapshot()
|
||||||
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
next.history, next.historyIndex, next.historyLimit = nil, 0, 0
|
||||||
|
pending := &EditorChanges{}
|
||||||
|
next.transactionChanges = pending
|
||||||
*e = next
|
*e = next
|
||||||
committed := false
|
committed := false
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -48,6 +83,11 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
changed := e.revision != before.revision
|
changed := e.revision != before.revision
|
||||||
|
changes := *pending
|
||||||
|
if e.transactionChanges != pending {
|
||||||
|
changes = EditorChanges{All: true}
|
||||||
|
}
|
||||||
|
e.transactionChanges = before.transactionChanges
|
||||||
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
||||||
if !sameBackend(e.backends.FontResources, before.backends.FontResources) {
|
if !sameBackend(e.backends.FontResources, before.backends.FontResources) {
|
||||||
e.clearFontResourceCache()
|
e.clearFontResourceCache()
|
||||||
@@ -62,7 +102,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
|||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
e.serial, e.revision = before.serial, before.revision
|
e.serial, e.revision = before.serial, before.revision
|
||||||
e.recordTransaction(before)
|
e.recordTransaction(before, changes)
|
||||||
}
|
}
|
||||||
committed = true
|
committed = true
|
||||||
return nil
|
return nil
|
||||||
@@ -108,6 +148,7 @@ func (e *Editor) Undo() bool {
|
|||||||
e.historyIndex--
|
e.historyIndex--
|
||||||
change := e.history[e.historyIndex]
|
change := e.history[e.historyIndex]
|
||||||
change.undo(e)
|
change.undo(e)
|
||||||
|
e.changes, e.objectIndex = change.changes, nil
|
||||||
e.revision = change.before
|
e.revision = change.before
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -120,6 +161,7 @@ func (e *Editor) Redo() bool {
|
|||||||
}
|
}
|
||||||
change := e.history[e.historyIndex]
|
change := e.history[e.historyIndex]
|
||||||
change.redo(e)
|
change.redo(e)
|
||||||
|
e.changes, e.objectIndex = change.changes, nil
|
||||||
e.historyIndex++
|
e.historyIndex++
|
||||||
e.revision = change.after
|
e.revision = change.after
|
||||||
return true
|
return true
|
||||||
@@ -132,15 +174,12 @@ func (e *Editor) Revision() uint64 {
|
|||||||
return e.revision
|
return e.revision
|
||||||
}
|
}
|
||||||
|
|
||||||
// transactionSnapshot 复制可变容器,保留只读XML、图元及二进制资源的共享
|
// transactionSnapshot 复制顶层容器,共享按替换方式更新的页面图层、图元和二进制资源
|
||||||
// 返回: Editor 独立事务状态
|
// 返回: Editor 独立事务状态
|
||||||
func (e *Editor) transactionSnapshot() Editor {
|
func (e *Editor) transactionSnapshot() Editor {
|
||||||
next := *e
|
next := *e
|
||||||
next.Info = cloneEditorData(e.Info)
|
next.Info = cloneEditorData(e.Info)
|
||||||
next.pages = make([]PageContent, len(e.pages))
|
next.pages = slices.Clone(e.pages)
|
||||||
for i, page := range e.pages {
|
|
||||||
next.pages[i] = copyEditorPage(page)
|
|
||||||
}
|
|
||||||
next.resources = slices.Clone(e.resources)
|
next.resources = slices.Clone(e.resources)
|
||||||
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
|
next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID)
|
||||||
next.origins = maps.Clone(e.origins)
|
next.origins = maps.Clone(e.origins)
|
||||||
@@ -213,9 +252,13 @@ func (e *Editor) retainDocumentResources(source *Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordTransaction 记录事务快照,在各自资源视图中保留后续注册的资源
|
// recordTransaction 记录事务快照,在各自资源视图中保留后续注册的资源
|
||||||
// 入参: before 修改前的文档状态
|
// 入参: before 修改前的文档状态, changes 可选的事务变化范围
|
||||||
func (e *Editor) recordTransaction(before Editor) {
|
func (e *Editor) recordTransaction(before Editor, changes ...EditorChanges) {
|
||||||
if change := e.recordChange(); change != nil {
|
scope := EditorChanges{All: true}
|
||||||
|
if len(changes) != 0 {
|
||||||
|
scope = changes[0]
|
||||||
|
}
|
||||||
|
if change := e.recordChangeScope(scope); change != nil {
|
||||||
after := e.transactionSnapshot()
|
after := e.transactionSnapshot()
|
||||||
before.history, after.history = nil, nil
|
before.history, after.history = nil, nil
|
||||||
change.undo = func(e *Editor) {
|
change.undo = func(e *Editor) {
|
||||||
@@ -230,10 +273,23 @@ func (e *Editor) recordTransaction(before Editor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
||||||
|
// 入参: pages 受影响页码,省略表示全局变化
|
||||||
// 返回: *editorChange 记录,未启用历史时为nil
|
// 返回: *editorChange 记录,未启用历史时为nil
|
||||||
func (e *Editor) recordChange() *editorChange {
|
func (e *Editor) recordChange(pages ...int) *editorChange {
|
||||||
|
return e.recordChangeScope(EditorChanges{All: len(pages) == 0, Pages: slices.Clone(pages)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordChangeScope 分配修订并记录变更范围,事务中同步合并
|
||||||
|
// 入参: scope 变更范围
|
||||||
|
// 返回: *editorChange 可补充回放操作的记录
|
||||||
|
func (e *Editor) recordChangeScope(scope EditorChanges) *editorChange {
|
||||||
|
e.objectIndex = nil
|
||||||
|
e.changes = scope
|
||||||
|
if e.transactionChanges != nil {
|
||||||
|
e.transactionChanges.merge(scope)
|
||||||
|
}
|
||||||
e.serial++
|
e.serial++
|
||||||
change := editorChange{before: e.revision, after: e.serial}
|
change := editorChange{before: e.revision, after: e.serial, changes: scope}
|
||||||
e.revision = e.serial
|
e.revision = e.serial
|
||||||
if e.historyLimit == 0 {
|
if e.historyLimit == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+94
-29
@@ -112,63 +112,107 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag
|
|||||||
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
|
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
|
||||||
// 返回: []string 新对象标识, error 校验、迁移或取消错误
|
// 返回: []string 新对象标识, error 校验、迁移或取消错误
|
||||||
func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]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)
|
progress := editorProgress(options.OnProgress).withContext(ctx)
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if !finite(options.DX) || !finite(options.DY) {
|
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 {
|
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
|
||||||
return nil, ErrEncryptionPolicyRequired
|
return nil, nil, ErrEncryptionPolicyRequired
|
||||||
}
|
}
|
||||||
objects, err := source.Objects(sourcePage, ids)
|
objects, err := source.Objects(sourcePage, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
target, err := e.page(page)
|
target, err := e.page(page)
|
||||||
if err != nil || len(objects) == 0 {
|
if err != nil || len(objects) == 0 && len(annotations) == 0 {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if source == e {
|
if source == e {
|
||||||
if err := progress.report("commit", 0, 0); err != nil {
|
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)
|
reader, err := source.readerContext(ctx, progress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
doc, err := reader.Doc()
|
doc, err := reader.Doc()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
ref := doc.Pages.Page[sourcePage]
|
ref := doc.Pages.Page[sourcePage]
|
||||||
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
|
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
|
||||||
if err != nil {
|
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)
|
pageName := reader.ResPath(ref.BaseLoc)
|
||||||
content, err := reader.PageContentByIndex(sourcePage)
|
content, err := reader.PageContentByIndex(sourcePage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, value := range content.PageRes {
|
for _, value := range content.PageRes {
|
||||||
name, err := migration.resolve(pageName, "", value)
|
name, err := migration.resolve(pageName, "", value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := migration.resourceIndex(name); err != nil {
|
if err := migration.resourceIndex(name); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
layers := make([][]byte, len(objects))
|
layers := make([][]byte, len(objects))
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
if err := progress.report("objects", i, len(objects)); err != nil {
|
if err := progress.report("objects", i, len(objects)); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
var attrs ofdAttrs
|
var attrs ofdAttrs
|
||||||
@@ -187,32 +231,48 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
|||||||
data, err = editorObjectXML(object)
|
data, err = editorObjectXML(object)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
data, err = editorXMLContainer("Layer", attrs, data)
|
data, err = editorXMLContainer("Layer", attrs, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
root, err := parseEditorXMLContext(ctx, data)
|
root, err := parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
||||||
if err != nil {
|
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 {
|
if err := migration.encodeResources(); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, resource := range e.resources {
|
for _, resource := range e.resources {
|
||||||
delete(migration.files, resource.name)
|
delete(migration.files, resource.name)
|
||||||
}
|
}
|
||||||
next, err := migration.merge(base, nil, nil, nil)
|
next, err := migration.merge(base, nil, nil, nil)
|
||||||
if err != 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 {
|
err = e.Transaction(func(editor *Editor) error {
|
||||||
if err := progress.report("commit", 0, 0); err != nil {
|
if err := progress.report("commit", 0, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -236,13 +296,13 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object := layer.Objects[0]
|
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)
|
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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}
|
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)
|
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 {
|
for i, object := range objects {
|
||||||
orderedIDs[i] = editorObjectID(object)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return copied, err
|
return copied, copiedNotes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
prefix := editorXMLNamespacePrefix(root)
|
prefix := editorXMLNamespacePrefix(root, "ofd")
|
||||||
var patches []editorXMLPatch
|
var patches []editorXMLPatch
|
||||||
var visit func(*editorXML, bool) error
|
var visit func(*editorXML, bool) error
|
||||||
visit = func(node *editorXML, declare bool) error {
|
visit = func(node *editorXML, declare bool) error {
|
||||||
@@ -152,9 +152,9 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
|
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
|
||||||
// 入参: node 节点
|
// 入参: node 节点, base 首选前缀
|
||||||
// 返回: string 可用前缀
|
// 返回: string 可用前缀
|
||||||
func editorXMLNamespacePrefix(node *editorXML) string {
|
func editorXMLNamespacePrefix(node *editorXML, base string) string {
|
||||||
used := make(map[string]bool)
|
used := make(map[string]bool)
|
||||||
collect := func(current *editorXML) {
|
collect := func(current *editorXML) {
|
||||||
for _, attr := range current.attrs {
|
for _, attr := range current.attrs {
|
||||||
@@ -174,9 +174,9 @@ func editorXMLNamespacePrefix(node *editorXML) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(node)
|
visit(node)
|
||||||
prefix := "ofd"
|
prefix := base
|
||||||
for index := 1; used[prefix]; index++ {
|
for index := 1; used[prefix]; index++ {
|
||||||
prefix = "ofd" + strconv.Itoa(index)
|
prefix = base + strconv.Itoa(index)
|
||||||
}
|
}
|
||||||
return prefix
|
return prefix
|
||||||
}
|
}
|
||||||
+44
-12
@@ -131,6 +131,9 @@ func (r *Reader) Editor() (*Editor, error) {
|
|||||||
// 入参: ctx 取消上下文
|
// 入参: ctx 取消上下文
|
||||||
// 返回: *Editor 编辑器, error 初始化或取消错误
|
// 返回: *Editor 编辑器, error 初始化或取消错误
|
||||||
func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) {
|
func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) {
|
||||||
|
if r.pageReady != nil {
|
||||||
|
return nil, ErrIncompleteDocument
|
||||||
|
}
|
||||||
_, err := r.DocContext(ctx)
|
_, err := r.DocContext(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -394,15 +397,18 @@ func (e *Editor) sourceRGB() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// repairSourceObjectIDs 按实际节点区分重复对象,只修复没有引用歧义的编号
|
// repairSourceObjectIDs 按实际节点区分重复对象,只修复没有引用歧义的编号
|
||||||
// 入参: data 页面原文, root 页面节点
|
// 入参: ctx 取消上下文, data 页面原文, root 页面节点
|
||||||
// 返回: []byte 编辑副本, bool 是否修复, error 错误信息
|
// 返回: []byte 编辑副本, bool 是否修复, error 错误信息
|
||||||
func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bool, error) {
|
func (e *Editor) repairSourceObjectIDs(ctx context.Context, data []byte, root *editorXML) ([]byte, bool, error) {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
duplicates := make(map[string]bool)
|
duplicates := make(map[string]bool)
|
||||||
var nodes []*editorXML
|
var nodes []*editorXML
|
||||||
var visit func(*editorXML) error
|
var visit func(*editorXML) error
|
||||||
visit = func(parent *editorXML) error {
|
visit = func(parent *editorXML) error {
|
||||||
for _, node := range parent.children {
|
for _, node := range parent.children {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !ofdXMLChild(parent.name, node.name) {
|
if !ofdXMLChild(parent.name, node.name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -437,15 +443,18 @@ func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bo
|
|||||||
if len(nodes) == 0 {
|
if len(nodes) == 0 {
|
||||||
return data, false, nil
|
return data, false, nil
|
||||||
}
|
}
|
||||||
if err := e.checkDuplicateObjectReferences(duplicates); err != nil {
|
if err := e.checkDuplicateObjectReferences(ctx, duplicates); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
if err := e.prepareSourceIDs(); err != nil {
|
if err := e.prepareSourceIDsContext(ctx); err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
maximum := e.maxID
|
maximum := e.maxID
|
||||||
var patches []editorXMLPatch
|
var patches []editorXMLPatch
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
maximum++
|
maximum++
|
||||||
encoded, err := editorXMLAttribute(data, node, "ID", strconv.Itoa(maximum))
|
encoded, err := editorXMLAttribute(data, node, "ID", strconv.Itoa(maximum))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -458,9 +467,9 @@ func (e *Editor) repairSourceObjectIDs(data []byte, root *editorXML) ([]byte, bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkDuplicateObjectReferences 检查编号引用的目标歧义
|
// checkDuplicateObjectReferences 检查编号引用的目标歧义
|
||||||
// 入参: duplicates 重复编号
|
// 入参: ctx 取消上下文, duplicates 重复编号
|
||||||
// 返回: error 引用歧义或读取错误
|
// 返回: error 引用歧义或读取错误
|
||||||
func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) error {
|
func (e *Editor) checkDuplicateObjectReferences(ctx context.Context, duplicates map[string]bool) error {
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
for name := range reader.fileNames {
|
for name := range reader.fileNames {
|
||||||
if !strings.EqualFold(path.Ext(name), ".xml") {
|
if !strings.EqualFold(path.Ext(name), ".xml") {
|
||||||
@@ -472,6 +481,10 @@ func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) erro
|
|||||||
}
|
}
|
||||||
decoder := xml.NewDecoder(input)
|
decoder := xml.NewDecoder(input)
|
||||||
for {
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
input.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
token, readErr := decoder.Token()
|
token, readErr := decoder.Token()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
input.Close()
|
input.Close()
|
||||||
@@ -497,6 +510,16 @@ func (e *Editor) checkDuplicateObjectReferences(duplicates map[string]bool) erro
|
|||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) loadSourcePage(index int) error {
|
func (e *Editor) loadSourcePage(index int) error {
|
||||||
|
return e.loadSourcePageContext(context.Background(), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSourcePageContext 解析原始页面,取消时不提交未完成页面
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: error 读取、解析或取消错误
|
||||||
|
func (e *Editor) loadSourcePageContext(ctx context.Context, index int) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
source := e.source.pages[e.pages[index].ID]
|
source := e.source.pages[e.pages[index].ID]
|
||||||
if source == nil || source.original != nil {
|
if source == nil || source.original != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -504,24 +527,24 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
loaded := *source
|
loaded := *source
|
||||||
source = &loaded
|
source = &loaded
|
||||||
reader := e.source.reader
|
reader := e.source.reader
|
||||||
data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
|
data, err := reader.readFileViewContext(ctx, reader.ResPath(source.ref.BaseLoc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
root, err := parseEditorXML(data)
|
root, err := parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
page, err := reader.PageContent(source.ref)
|
page, err := reader.PageContentContext(ctx, source.ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, repaired, err := e.repairSourceObjectIDs(data, root)
|
data, repaired, err := e.repairSourceObjectIDs(ctx, data, root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if repaired {
|
if repaired {
|
||||||
root, err = parseEditorXML(data)
|
root, err = parseEditorXMLContext(ctx, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -544,13 +567,17 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
}
|
}
|
||||||
source.data, source.root, source.nodes = data, root, nodes
|
source.data, source.root, source.nodes = data, root, nodes
|
||||||
source.repaired = repaired
|
source.repaired = repaired
|
||||||
|
origins := make(map[string]*editorObjectOrigin)
|
||||||
for i := range page.Content.Layer {
|
for i := range page.Content.Layer {
|
||||||
layer := &page.Content.Layer[i]
|
layer := &page.Content.Layer[i]
|
||||||
for j, object := range layer.Objects {
|
for j, object := range layer.Objects {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
id := editorObjectID(object)
|
id := editorObjectID(object)
|
||||||
if node := nodes[id]; node != nil {
|
if node := nodes[id]; node != nil {
|
||||||
origin := &editorObjectOrigin{page: source, data: data, node: node, object: object}
|
origin := &editorObjectOrigin{page: source, data: data, node: node, object: object}
|
||||||
e.source.origins[id] = origin
|
origins[id] = origin
|
||||||
if editorXMLTransformable(node) {
|
if editorXMLTransformable(node) {
|
||||||
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,6 +589,10 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
maps.Copy(e.source.origins, origins)
|
||||||
source.original = page
|
source.original = page
|
||||||
e.source.pages[source.ref.ID] = source
|
e.source.pages[source.ref.ID] = source
|
||||||
e.pages[index] = copyEditorPage(*page)
|
e.pages[index] = copyEditorPage(*page)
|
||||||
@@ -680,6 +711,7 @@ func editorPreservedObject(before, after GraphicObject) bool {
|
|||||||
}
|
}
|
||||||
before = cloneEditorData(before)
|
before = cloneEditorData(before)
|
||||||
before.state = after.state
|
before.state = after.state
|
||||||
|
before.richText = after.richText
|
||||||
switch before.Type {
|
switch before.Type {
|
||||||
case "TextObject":
|
case "TextObject":
|
||||||
a, b := &before.TextObject, after.TextObject
|
a, b := &before.TextObject, after.TextObject
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type editorReferenceScan struct {
|
|||||||
stack []string
|
stack []string
|
||||||
fonts []*editorFontUsage
|
fonts []*editorFontUsage
|
||||||
text strings.Builder
|
text strings.Builder
|
||||||
|
richText strings.Builder
|
||||||
resource bool
|
resource bool
|
||||||
capture bool
|
capture bool
|
||||||
safe bool
|
safe bool
|
||||||
@@ -140,14 +141,39 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
}
|
}
|
||||||
switch token := token.(type) {
|
switch token := token.(type) {
|
||||||
case xml.StartElement:
|
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
|
s.safe = false
|
||||||
return 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
|
s.safe = false
|
||||||
return 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 len(s.stack) == 0 {
|
||||||
if s.seen {
|
if s.seen {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
@@ -181,10 +207,11 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, attr := range token.Attr {
|
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
|
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
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -225,6 +252,9 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
s.capture = true
|
s.capture = true
|
||||||
}
|
}
|
||||||
case xml.CharData:
|
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 {
|
if len(s.stack) == 0 && len(bytes.Trim(token, " \t\r\n")) != 0 {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
@@ -233,6 +263,14 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
s.text.Write(token)
|
s.text.Write(token)
|
||||||
}
|
}
|
||||||
case xml.EndElement:
|
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 {
|
if len(s.stack) == 0 || s.stack[len(s.stack)-1] != token.Name.Local {
|
||||||
s.safe = false
|
s.safe = false
|
||||||
return false
|
return false
|
||||||
|
|||||||
+655
-121
@@ -25,6 +25,7 @@ import (
|
|||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ type richTextMetadata struct {
|
|||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// richTextSnapshot 保存对象及嵌套成员的不可变编辑语义,成员按绘制顺序定位
|
||||||
|
type richTextSnapshot struct {
|
||||||
|
metadata *richTextMetadata
|
||||||
|
children map[int]*richTextSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
// richTextEntry 保存标引入口及延迟报告的损坏诊断
|
// richTextEntry 保存标引入口及延迟报告的损坏诊断
|
||||||
type richTextEntry struct {
|
type richTextEntry struct {
|
||||||
index int
|
index int
|
||||||
@@ -56,11 +63,13 @@ type richTextEntry struct {
|
|||||||
type richTextIndex struct {
|
type richTextIndex struct {
|
||||||
source *editorSource
|
source *editorSource
|
||||||
revision uint64
|
revision uint64
|
||||||
|
remaps map[string]int
|
||||||
entries map[string]richTextEntry
|
entries map[string]richTextEntry
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
||||||
|
// 按给定宽度排版,高度随内容自适应
|
||||||
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
||||||
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
||||||
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
||||||
@@ -88,12 +97,30 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error)
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换
|
// UpdateRichText 原子替换富文本内容,保留对象标识、段落宽度及页面变换,高度随内容调整
|
||||||
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
|
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
|
||||||
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
||||||
// 返回: error 识别、排版或提交错误
|
// 返回: error 识别、排版或提交错误
|
||||||
func (e *Editor) UpdateRichText(page int, id string, value RichText) 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 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||||
@@ -107,29 +134,28 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return e.updateRichTextFrame(page, id, value, offset, width)
|
return e.updateRichTextFrame(page, id, value, offset, width, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度
|
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时沿用原宽度模式
|
||||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度
|
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排原文
|
||||||
// 返回: error 校验或提交错误
|
// 返回: 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 {
|
return e.Transaction(func(e *Editor) error {
|
||||||
old, err := e.Object(page, id)
|
old, err := e.Object(page, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current, err := e.RichText(page, id)
|
current, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
frame, matrix, err := e.RichTextFrame(page, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := value.Validate(); err != nil {
|
if err := value.Validate(); err != nil {
|
||||||
return err
|
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)) {
|
if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -204,13 +230,20 @@ func (e *Editor) RichText(page int, id string) (RichText, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
return RichText{}, err
|
||||||
}
|
}
|
||||||
|
return e.richTextObject(page, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// richTextObject 读取对象的编辑标引并校验标准内容
|
||||||
|
// 入参: page 页面索引, object 当前对象
|
||||||
|
// 返回: RichText 编辑内容, error 识别或持久化错误
|
||||||
|
func (e *Editor) richTextObject(page int, object GraphicObject) (RichText, error) {
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
return e.promoteRichText(page, object)
|
return e.promoteRichText(page, object)
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" {
|
||||||
return RichText{}, ErrNotRichText
|
return RichText{}, ErrNotRichText
|
||||||
}
|
}
|
||||||
entry, err := e.richTextEntry(page, id)
|
entry, err := e.richTextEntry(page, editorObjectID(object))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
return RichText{}, err
|
||||||
}
|
}
|
||||||
@@ -247,19 +280,8 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
|||||||
return Box{}, Matrix{}, err
|
return Box{}, Matrix{}, err
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
if _, err := e.promoteRichText(page, object); err != nil {
|
_, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, false)
|
||||||
return Box{}, Matrix{}, err
|
return frame, 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
|
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" {
|
||||||
return Box{}, Matrix{}, ErrNotRichText
|
return Box{}, Matrix{}, ErrNotRichText
|
||||||
@@ -275,7 +297,181 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
|||||||
return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引
|
// AddCompositeRichText 向内部范围添加富文本,使用页面坐标并保存编辑标引
|
||||||
|
// 入参: page 页面索引, path 父路径, box 页面编辑框, value 富文本
|
||||||
|
// 返回: int 新成员序号, error 排版或提交错误
|
||||||
|
func (e *Editor) AddCompositeRichText(page int, path ObjectPath, box Box, value RichText) (int, error) {
|
||||||
|
var index int
|
||||||
|
err := e.Transaction(func(e *Editor) error {
|
||||||
|
if _, err := creationBox(editorBoxString(box)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
object, meta, err := e.prepareRichText(box, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
index, err = e.AddCompositeObject(page, path, object)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member, err := e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
|
||||||
|
})
|
||||||
|
return index, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompositeRichText 读取内部富文本值及局部编辑框,不提升未标引的普通文字
|
||||||
|
// 入参: page 页面索引, member 当前内部成员快照
|
||||||
|
// 返回: RichText 编辑内容, Box 局部编辑框, Matrix 页面变换, error 识别或持久化错误
|
||||||
|
func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText, Box, Matrix, error) {
|
||||||
|
if _, err := e.page(page); err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
if member.Object.Type != "CompositeObject" {
|
||||||
|
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||||
|
}
|
||||||
|
value, err := e.richTextObject(page, member.Object)
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
entry, err := e.richTextEntry(page, editorObjectID(member.Object))
|
||||||
|
if err != nil {
|
||||||
|
return RichText{}, Box{}, Matrix{}, err
|
||||||
|
}
|
||||||
|
return value, entry.metadata.Frame, member.Matrix.Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCompositeRichText 原子重排内部富文本,保留父变换并隔离共享实例
|
||||||
|
// 入参: 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 {
|
||||||
|
node := nodes[0]
|
||||||
|
current, frame, _, err := e.CompositeRichText(page, members[0])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read composite member %s: %w", editorObjectID(members[0].Object), err)
|
||||||
|
}
|
||||||
|
if err := value.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if node.object.CompositeGraphicUnit.Clips != nil {
|
||||||
|
return fmt.Errorf("clipped rich text requires explicit clip removal before reflow")
|
||||||
|
}
|
||||||
|
entry, err := e.richTextEntry(page, editorObjectID(node.object))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read composite node %s: %w", editorObjectID(node.object), err)
|
||||||
|
}
|
||||||
|
object := cloneEditorData(node.object)
|
||||||
|
if !node.boundaryInCTM {
|
||||||
|
object = compositeBoundary(object, node.parent, true)
|
||||||
|
}
|
||||||
|
unit := &object.CompositeGraphicUnit
|
||||||
|
box, err := creationBox(unit.Boundary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(unit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0]+offset, entry.metadata.Offset[1]))
|
||||||
|
if width > 0 {
|
||||||
|
frame.W = width
|
||||||
|
}
|
||||||
|
next, meta, err := e.prepareRichText(frame, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
placement := richTextPlacement(meta, matrix)
|
||||||
|
unit.ResourceID, unit.Boundary, unit.CTM = next.CompositeGraphicUnit.ResourceID, placement.Boundary, placement.CTM
|
||||||
|
if !node.boundaryInCTM {
|
||||||
|
object = compositeBoundary(object, node.parent, false)
|
||||||
|
}
|
||||||
|
metadata, entryIndex, changed = meta, entry.index, true
|
||||||
|
return node.update(object)
|
||||||
|
})
|
||||||
|
if err != nil || !changed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
member, err = e.CompositeObject(page, path, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry, err := e.richTextEntry(page, editorObjectID(member.Object)); err == nil {
|
||||||
|
entryIndex = entry.index
|
||||||
|
} else if !errors.Is(err, ErrNotRichText) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.putCompositeRichTextMetadata(page, member.Object, metadata, entryIndex)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// putCompositeRichTextMetadata 在容器坐标规范化后更新标准内容摘要和编辑标引
|
||||||
|
// 入参: page 页面索引, object 当前成员, meta 编辑信息, index 原入口或-1
|
||||||
|
// 返回: error 资源或提交错误
|
||||||
|
func (e *Editor) putCompositeRichTextMetadata(page int, object GraphicObject, meta richTextMetadata, index int) error {
|
||||||
|
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects)
|
||||||
|
return e.putRichTextMetadata(page, editorObjectID(object), meta, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// putCompositeRichTextCopies 一次读取复制后的选区,按对象顺序恢复富文本标引
|
||||||
|
// 入参: page 页面索引, path 父路径, indexes 新成员序号, metadata 选区位置对应的编辑语义
|
||||||
|
// 返回: 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([]*richTextSnapshot, 0, len(metadata))
|
||||||
|
for i, index := range indexes {
|
||||||
|
if meta, ok := metadata[i]; ok {
|
||||||
|
selected = append(selected, index)
|
||||||
|
values = append(values, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
members, err := e.CompositeObjectsAt(page, path, selected)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, member := range members {
|
||||||
|
if err := e.putRichTextSnapshot(page, member.Object, values[i]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引,高度随排版内容自适应
|
||||||
// 入参: box 页面或局部编辑框, value 富文本
|
// 入参: box 页面或局部编辑框, value 富文本
|
||||||
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
||||||
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
||||||
@@ -289,8 +485,9 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, richTextMetadata{}, err
|
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)
|
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 {
|
if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 {
|
||||||
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
|
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
|
||||||
}
|
}
|
||||||
@@ -313,7 +510,7 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphicObject{}, richTextMetadata{}, err
|
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 {
|
for i := range meta.Value.Runs {
|
||||||
meta.Value.Runs[i].Format.Font = ""
|
meta.Value.Runs[i].Format.Font = ""
|
||||||
}
|
}
|
||||||
@@ -385,9 +582,15 @@ func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) {
|
|||||||
// 返回: *richTextIndex 只读缓存
|
// 返回: *richTextIndex 只读缓存
|
||||||
func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
||||||
if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision {
|
if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision {
|
||||||
return index
|
valid := len(index.remaps) == len(e.structureRemaps)
|
||||||
|
for page, remaps := range e.structureRemaps {
|
||||||
|
valid = valid && index.remaps[page] == len(remaps)
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
return index
|
||||||
|
}
|
||||||
}
|
}
|
||||||
index := &richTextIndex{source: e.source, revision: e.revision, entries: make(map[string]richTextEntry)}
|
index := &richTextIndex{source: e.source, revision: e.revision, remaps: e.structureRemapStarts(), entries: make(map[string]richTextEntry)}
|
||||||
e.richIndex = index
|
e.richIndex = index
|
||||||
if e.source == nil {
|
if e.source == nil {
|
||||||
return index
|
return index
|
||||||
@@ -434,6 +637,17 @@ func (e *Editor) richTextMetadataIndex() *richTextIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for page, remaps := range e.structureRemaps {
|
||||||
|
for _, ids := range remaps {
|
||||||
|
for from, to := range ids {
|
||||||
|
if _, exists := index.entries[page+"/"+to]; !exists {
|
||||||
|
if entry, ok := index.entries[page+"/"+from]; ok {
|
||||||
|
index.entries[page+"/"+to] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,7 +661,26 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
|
|||||||
}
|
}
|
||||||
root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)}
|
root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)}
|
||||||
if index >= 0 {
|
if index >= 0 {
|
||||||
return e.UpdateStructureNode(index, nil, root)
|
separate := false
|
||||||
|
err := e.editStructure(index, func(data []byte, node *editorXML, tag CustomTag) ([]byte, error) {
|
||||||
|
updated, err := rewriteStructureReferences(context.Background(), data, tag, func(pageID string, ids []string) (string, []string, error) {
|
||||||
|
var remaining []string
|
||||||
|
for _, objectID := range ids {
|
||||||
|
if editorResourceID(pageID) != editorResourceID(e.pages[page].ID) || editorResourceID(objectID) != editorResourceID(id) {
|
||||||
|
remaining = append(remaining, objectID)
|
||||||
|
separate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pageID, remaining, nil
|
||||||
|
})
|
||||||
|
if err != nil || separate {
|
||||||
|
return updated, err
|
||||||
|
}
|
||||||
|
return updateStructureNodeXML(data, node, root)
|
||||||
|
})
|
||||||
|
if err != nil || !separate {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}}
|
root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}}
|
||||||
_, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root)
|
_, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root)
|
||||||
@@ -458,25 +691,31 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
|
|||||||
// 入参: page 页面索引, object 普通文字快照
|
// 入参: page 页面索引, object 普通文字快照
|
||||||
// 返回: RichText 单片段, error 不安全提升错误
|
// 返回: RichText 单片段, error 不安全提升错误
|
||||||
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
|
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
|
||||||
t := object.TextObject
|
layer, _, err := e.findObject(page, object.TextObject.ID)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RichText{}, err
|
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 {
|
if origin := e.objectOrigin(object.TextObject.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||||
return unsupported()
|
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) {
|
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
return unsupported()
|
|
||||||
}
|
|
||||||
frame, err := t.TextFrame()
|
|
||||||
if err != nil {
|
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()
|
return unsupported()
|
||||||
}
|
}
|
||||||
if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) {
|
if _, err := t.TextFrame(); err != nil {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
value, layout := t.TextLayout()
|
value, layout := t.TextLayout()
|
||||||
@@ -492,7 +731,7 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
|||||||
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
if !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||||
return unsupported()
|
return unsupported()
|
||||||
}
|
}
|
||||||
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
||||||
@@ -520,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)
|
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()
|
return result, result.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局
|
// richPlainOffset 计算原首行相对标准排版的位移,保留既有段落和组合内基线
|
||||||
// 入参: text 已校验的普通文字
|
// 入参: text 已校验的普通文字
|
||||||
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
||||||
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||||
if text.layout != nil {
|
if len(text.TextCode) == 0 {
|
||||||
return 0, 0, nil
|
return 0, 0, fmt.Errorf("text codes are empty")
|
||||||
}
|
|
||||||
if len(text.TextCode) != 1 {
|
|
||||||
return 0, 0, fmt.Errorf("positioned text requires explicit layout")
|
|
||||||
}
|
}
|
||||||
|
value, layout := text.TextLayout()
|
||||||
check := text
|
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
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
||||||
@@ -550,32 +787,60 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, err
|
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 复制已识别组合的语义,使用目标标准子对象的字体引用
|
// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
|
||||||
// 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识
|
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, copied 新正文标识, copiedNotes 新注解标识
|
||||||
// 返回: error 元数据损坏或提交错误
|
// 返回: error 元数据损坏、结构不一致或提交错误
|
||||||
func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) 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 {
|
for i, id := range ids {
|
||||||
object, err := source.Object(sourcePage, id)
|
before, err := source.Object(sourcePage, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if object.Type != "CompositeObject" {
|
after, err := e.Object(page, copied[i])
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry, err := source.richTextEntry(sourcePage, id)
|
|
||||||
if errors.Is(err, ErrNotRichText) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := source.RichText(sourcePage, id); err != nil {
|
if err := copyObject(before, after); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,25 +879,18 @@ func (e *Editor) richTextColors(object *GraphicObject) error {
|
|||||||
|
|
||||||
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
|
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
|
||||||
// 入参: objects 待复制快照
|
// 入参: objects 待复制快照
|
||||||
// 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断
|
// 返回: map[int]*richTextSnapshot 需要复制的语义, error 损坏诊断
|
||||||
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) {
|
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]*richTextSnapshot, error) {
|
||||||
result := make(map[int]richTextMetadata)
|
result := make(map[int]*richTextSnapshot)
|
||||||
for i, object := range objects {
|
for i, object := range objects {
|
||||||
if object.Type != "CompositeObject" {
|
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if object.origin != nil && e.snapshotOrigin(object) == nil {
|
if object.origin != nil && e.snapshotOrigin(object) == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if object.richText != nil {
|
if object.richText != nil {
|
||||||
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
result[i] = object.richText
|
||||||
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)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
index := e.richTextMetadataIndex()
|
index := e.richTextMetadataIndex()
|
||||||
@@ -642,45 +900,125 @@ func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTe
|
|||||||
if len(index.entries) == 0 {
|
if len(index.entries) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := editorObjectID(object)
|
|
||||||
for page := range e.pages {
|
for page := range e.pages {
|
||||||
entry, err := e.richTextEntry(page, id)
|
snapshot, err := e.captureRichTextSnapshot(page, object)
|
||||||
if errors.Is(err, ErrNotRichText) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
current, err := e.Object(page, id)
|
if snapshot != nil {
|
||||||
if err != nil {
|
result[i] = snapshot
|
||||||
continue
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在
|
// attachRichTextSnapshot 为对象快照附加嵌套编辑语义,来源删除后仍可跨页粘贴
|
||||||
// 入参: page 页面索引, object 对象快照
|
// 入参: page 页面索引, object 对象快照
|
||||||
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) {
|
// 返回: error 标引或资源错误
|
||||||
object.richText = nil
|
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) error {
|
||||||
if object.Type != "CompositeObject" {
|
snapshot, err := e.captureRichTextSnapshot(page, *object)
|
||||||
return
|
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))
|
index := e.richTextMetadataIndex()
|
||||||
if err != nil {
|
if index.err != nil || len(index.entries) == 0 {
|
||||||
return
|
return nil, index.err
|
||||||
}
|
}
|
||||||
meta := cloneEditorData(entry.metadata)
|
active := make(map[string]bool)
|
||||||
object.richText = &meta
|
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 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
|
// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
|
||||||
@@ -708,22 +1046,7 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
|
|||||||
if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 {
|
if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
var meta richTextMetadata
|
if !richTextMetadataFontSafe(root.Text) {
|
||||||
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 {
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
for _, child := range root.Children {
|
for _, child := range root.Children {
|
||||||
@@ -734,3 +1057,214 @@ func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool,
|
|||||||
}
|
}
|
||||||
return true, ctx.Err()
|
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
@@ -15,18 +15,29 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ObjectSelection 保存正文与注解的独立快照,共享当前编辑器的不可变资源
|
// ObjectSelection 保存正文、注解及组合成员的独立快照,共享当前编辑器的不可变资源
|
||||||
// 来源修改、删除或撤销不改变快照,不用于跨文档传输
|
// 来源修改、删除或撤销不改变快照,不用于跨文档传输
|
||||||
type ObjectSelection struct {
|
type ObjectSelection struct {
|
||||||
editor *Editor
|
editor *Editor
|
||||||
document editorDocumentIdentity
|
document editorDocumentIdentity
|
||||||
objects []GraphicObject
|
objects []GraphicObject
|
||||||
annotations *AnnotationSelection
|
annotations *AnnotationSelection
|
||||||
|
parts []editorSelectionPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorSelectionPart 保存按绘制顺序排列的顶层或内部对象快照
|
||||||
|
type editorSelectionPart struct {
|
||||||
|
objects []GraphicObject
|
||||||
|
composite *CompositeSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectionBounds 获取正文与注解混合选区的实际范围,复用同一次度量的资源缓存
|
// SelectionBounds 获取正文与注解混合选区的实际范围,复用同一次度量的资源缓存
|
||||||
@@ -400,6 +411,18 @@ func (e *Editor) PasteSelection(page int, selection *ObjectSelection, dx, dy flo
|
|||||||
if err == nil && selection.annotations != nil {
|
if err == nil && selection.annotations != nil {
|
||||||
annotations, err = edit.PasteAnnotations(page, selection.annotations, dx, dy)
|
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
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -418,5 +441,770 @@ func (e *Editor) PasteSelectionToComposite(page int, path ObjectPath, selection
|
|||||||
if selection.annotations != nil {
|
if selection.annotations != nil {
|
||||||
return nil, fmt.Errorf("annotations cannot be nested in a page object")
|
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] = ©
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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{})
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -337,7 +338,7 @@ func editorStructureIndexContext(ctx context.Context, base *editorSource, parts
|
|||||||
if base.document.CustomTags.Path == "" {
|
if base.document.CustomTags.Path == "" {
|
||||||
container := root.child("CustomTags")
|
container := root.child("CustomTags")
|
||||||
if container == nil || len(container.children) == 0 {
|
if container == nil || len(container.children) == 0 {
|
||||||
name = packageAvailableName(base.reader, nil, base.reader.RootDir+"/CustomTags.xml")
|
name = packageAvailableName(base.reader, nil, path.Join(base.reader.RootDir, "CustomTags.xml"))
|
||||||
data, err = root.containerXML("CustomTags", nil, nil)
|
data, err = root.containerXML("CustomTags", nil, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
root, err = parseEditorXMLContext(ctx, data)
|
root, err = parseEditorXMLContext(ctx, data)
|
||||||
|
|||||||
+71
-11
@@ -15,6 +15,9 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
@@ -34,8 +37,8 @@ type MissingGlyphError struct {
|
|||||||
|
|
||||||
// TextLayout 本地横向段落选项
|
// TextLayout 本地横向段落选项
|
||||||
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
|
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
|
||||||
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留
|
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存
|
||||||
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
||||||
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
||||||
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
|
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
|
||||||
@@ -63,10 +66,50 @@ type TextStyle struct {
|
|||||||
Reflow bool
|
Reflow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染
|
// textLayout 保存可恢复的原文与排版选项,不参与阅读渲染
|
||||||
type textLayout struct {
|
type textLayout struct {
|
||||||
value string
|
value string
|
||||||
options TextLayout
|
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码点
|
// Error 返回缺失字符的Unicode码点
|
||||||
@@ -106,7 +149,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error)
|
|||||||
return e.UpdateObjects(page, updates)
|
return e.UpdateObjects(page, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排
|
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排
|
||||||
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
||||||
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -187,7 +230,7 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
|
|||||||
|
|
||||||
// RewriteText 保留原文的字形定位,不修改对象样式或文档
|
// RewriteText 保留原文的字形定位,不修改对象样式或文档
|
||||||
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
|
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
|
||||||
// 方向、缩放和修饰原样保留;复杂布局仅支持等长替换,合并字形需显式重排
|
// 方向、缩放和修饰原样保留;单段纵排沿用现有纵向步进,其他复杂布局仅支持等长替换,合并字形需显式重排
|
||||||
// 首段缺失坐标仅在临时校验中恢复,不改写源坐标
|
// 首段缺失坐标仅在临时校验中恢复,不改写源坐标
|
||||||
// 入参: obj 原文字对象, value 新内容
|
// 入参: obj 原文字对象, value 新内容
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -210,7 +253,8 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
old, next := []rune(obj.Text()), []rune(value)
|
old, next := []rune(obj.Text()), []rune(value)
|
||||||
complex := obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
|
vertical := (obj.ReadDirection == 90 || obj.ReadDirection == 270) && obj.CharDirection == 0 && len(obj.TextCode) == 1 && obj.TextCode[0].DeltaY != ""
|
||||||
|
complex := obj.ReadDirection != 0 && !vertical || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
|
||||||
if complex && len(old) != len(next) {
|
if complex && len(old) != len(next) {
|
||||||
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
|
||||||
}
|
}
|
||||||
@@ -279,7 +323,12 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
|
|||||||
if obj.HScale != 0 {
|
if obj.HScale != 0 {
|
||||||
unit *= obj.HScale
|
unit *= obj.HScale
|
||||||
}
|
}
|
||||||
advance := func(char rune) float64 { return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit }
|
advance := func(char rune) float64 {
|
||||||
|
if vertical {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit
|
||||||
|
}
|
||||||
if codes[i].DeltaX != "" {
|
if codes[i].DeltaX != "" {
|
||||||
codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance)
|
codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance)
|
||||||
}
|
}
|
||||||
@@ -627,9 +676,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
|
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
|
||||||
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距
|
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点
|
||||||
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
// 返回: 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)
|
end := len(runes)
|
||||||
for end > 0 && runes[end-1] == ' ' {
|
for end > 0 && runes[end-1] == ' ' {
|
||||||
end--
|
end--
|
||||||
@@ -659,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))
|
deltas := make([]string, max(0, len(runes)-1))
|
||||||
for i := range deltas {
|
for i := range deltas {
|
||||||
advance := advances[i]
|
advance := advances[i]
|
||||||
|
|||||||
@@ -817,7 +817,7 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
|||||||
}
|
}
|
||||||
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
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 {
|
for i, node := range nodes {
|
||||||
if selection.Indexes[i] != first.Index {
|
if selection.Indexes[i] != first.Index {
|
||||||
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
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{})
|
node.setState(editorCompositeState{})
|
||||||
}
|
}
|
||||||
return nil
|
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
@@ -397,6 +397,9 @@ func editorXMLAttributes(node *editorXML, allowed string) bool {
|
|||||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if textMetadataAttribute(node.name, attr) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -569,6 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes := make(map[string]string)
|
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 {
|
if legacyClip {
|
||||||
changes["TransFlag"] = ""
|
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])
|
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
|
return bytes.Clone(data[node.start:node.end]), nil
|
||||||
}
|
}
|
||||||
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
start := token.(xml.StartElement)
|
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 {
|
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})
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace})
|
||||||
}
|
}
|
||||||
var result bytes.Buffer
|
var result bytes.Buffer
|
||||||
|
|||||||
@@ -17,14 +17,112 @@ package ofdgo
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
|
||||||
|
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
|
||||||
type FontFace = basefont.Face
|
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 激进修复字体数据
|
// FixFontDataAggressive 激进修复字体数据
|
||||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||||
|
|||||||
+41
-1
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
|
|||||||
return r.fontData(id, false)
|
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 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
||||||
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
||||||
// 返回: []byte 字体数据, error 资源或集合错误
|
// 返回: []byte 字体数据, error 资源或集合错误
|
||||||
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
|
|||||||
faces = make([]*FontFace, count)
|
faces = make([]*FontFace, count)
|
||||||
for index := range faces {
|
for index := range faces {
|
||||||
if tables, err := basefont.FileTables(data, index); err == nil {
|
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
|
faces[index] = &info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ type GraphicObject struct {
|
|||||||
CompositeGraphicUnit CompositeGraphicUnit
|
CompositeGraphicUnit CompositeGraphicUnit
|
||||||
origin *editorObjectOrigin
|
origin *editorObjectOrigin
|
||||||
state editorCompositeState
|
state editorCompositeState
|
||||||
richText *richTextMetadata
|
richText *richTextSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clips 相对于对象外接矩形的裁剪集合
|
// Clips 相对于对象外接矩形的裁剪集合
|
||||||
|
|||||||
+279
-70
@@ -15,6 +15,7 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -69,6 +70,25 @@ type PDFImportReport struct {
|
|||||||
Warnings []pdfgo.Diagnostic
|
Warnings []pdfgo.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PDFImport 按页转换PDF,实例需串行使用,输入源由调用方持有至Close
|
||||||
|
// 页面阅读快照独立于后续转换,Finish完成全局结构后交付独立编辑文档
|
||||||
|
// 转换失败后可用Reset重建,输入及回调在会话存续期间须保持有效
|
||||||
|
type PDFImport struct {
|
||||||
|
input *Input
|
||||||
|
importer *pdfImporter
|
||||||
|
options PDFImportOptions
|
||||||
|
pages []PageContent
|
||||||
|
ready []bool
|
||||||
|
err error
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// pdfImportInput 将输入读取绑定到当前转换上下文
|
||||||
|
type pdfImportInput struct {
|
||||||
|
input *Input
|
||||||
|
importer *pdfImporter
|
||||||
|
}
|
||||||
|
|
||||||
// pdfImporter 保存单次转换的对象与资源状态
|
// pdfImporter 保存单次转换的对象与资源状态
|
||||||
type pdfImporter struct {
|
type pdfImporter struct {
|
||||||
resources *Resources
|
resources *Resources
|
||||||
@@ -212,21 +232,34 @@ func (o pdfObjectOrigin) merge(other pdfObjectOrigin) pdfObjectOrigin {
|
|||||||
// 入参: ctx 取消上下文, input 输入源, options 转换选项
|
// 入参: ctx 取消上下文, input 输入源, options 转换选项
|
||||||
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息
|
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 错误信息
|
||||||
func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) {
|
func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Editor, PDFImportReport, error) {
|
||||||
|
pending, err := NewPDFImport(ctx, input, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, PDFImportReport{}, err
|
||||||
|
}
|
||||||
|
defer pending.Close()
|
||||||
|
return pending.Finish(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPDFImport 建立PDF页面索引和文档信息,不转换页面图元
|
||||||
|
// 内部复制输入密码供Reset使用,Close时清除;其他选项引用需保持有效
|
||||||
|
// 入参: ctx 取消上下文, input 调用方持有的输入源, options 转换选项
|
||||||
|
// 返回: *PDFImport 增量转换会话, error 读取错误
|
||||||
|
func NewPDFImport(ctx context.Context, input *Input, options PDFImportOptions) (*PDFImport, error) {
|
||||||
if options.Resources == nil {
|
if options.Resources == nil {
|
||||||
options.Resources, _ = NewResources(ResourceOptions{})
|
options.Resources, _ = NewResources(ResourceOptions{})
|
||||||
}
|
}
|
||||||
ctx = options.Resources.context(ctx)
|
ctx = options.Resources.context(ctx)
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("missing source")
|
return nil, fmt.Errorf("missing source")
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := options.Compression.Validate(); err != nil {
|
if err := options.Compression.Validate(); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
|
if !finite(options.RasterDPI) || options.RasterDPI < 0 {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
|
return nil, fmt.Errorf("invalid PDF raster DPI")
|
||||||
}
|
}
|
||||||
renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...)
|
renderer := NewRenderer(&Reader{resources: options.Resources}, options.RendererOptions...)
|
||||||
if options.Backends != nil {
|
if options.Backends != nil {
|
||||||
@@ -236,18 +269,25 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
renderer.DPI = options.RasterDPI
|
renderer.DPI = options.RasterDPI
|
||||||
}
|
}
|
||||||
if !finite(renderer.DPI) || renderer.DPI <= 0 {
|
if !finite(renderer.DPI) || renderer.DPI <= 0 {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI")
|
return nil, fmt.Errorf("invalid PDF raster DPI")
|
||||||
}
|
}
|
||||||
|
keep := false
|
||||||
|
options.Password = slices.Clone(options.Password)
|
||||||
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
clear(options.Password)
|
||||||
|
}
|
||||||
|
}()
|
||||||
progress := editorProgress(options.OnProgress).withContext(ctx)
|
progress := editorProgress(options.OnProgress).withContext(ctx)
|
||||||
if err := progress.report("open", 0, 0); err != nil {
|
if err := progress.report("open", 0, 0); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
editor := NewEditor()
|
editor := NewEditor()
|
||||||
editor.runtimeResources = options.Resources
|
editor.runtimeResources = options.Resources
|
||||||
editor.SetRenderBackends(renderer.Backends())
|
editor.SetRenderBackends(renderer.Backends())
|
||||||
editor.fontDirs, editor.fontFS = renderer.FontSources()
|
editor.fontDirs, editor.fontFS = renderer.FontSources()
|
||||||
editor.fontSourcesCache = renderer.fontSourcesCache
|
editor.fontSourcesCache = renderer.fontSourcesCache
|
||||||
importer := pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}}
|
importer := &pdfImporter{resources: options.Resources, ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, page: -1, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}, structureBindings: &pdfStructureBindings{}}
|
||||||
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText}
|
readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText}
|
||||||
if !options.Strict {
|
if !options.Strict {
|
||||||
importer.warning = func(warning pdfgo.Diagnostic) {
|
importer.warning = func(warning pdfgo.Diagnostic) {
|
||||||
@@ -256,24 +296,43 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
}
|
}
|
||||||
readOptions.Warning = importer.warning
|
readOptions.Warning = importer.warning
|
||||||
}
|
}
|
||||||
reader, err := pdfgo.NewReaderWithOptions(convertReader{ctx: ctx, input: input}, input.Size(), readOptions)
|
reader, err := pdfgo.NewReaderWithOptions(&pdfImportInput{input: input, importer: importer}, input.Size(), readOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
reader.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
importer.reader = reader
|
importer.reader = reader
|
||||||
if err := importer.prepareStructure(ctx); err != nil {
|
if err := importer.prepareStructure(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
return nil, fmt.Errorf("import PDF structure: %w", err)
|
||||||
}
|
}
|
||||||
outputIntents, err := reader.OutputIntents()
|
outputIntents, err := reader.OutputIntents()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF output intents: %w", err)
|
return nil, fmt.Errorf("import PDF output intents: %w", err)
|
||||||
}
|
}
|
||||||
if len(outputIntents) != 0 {
|
if len(outputIntents) != 0 {
|
||||||
if options.Strict {
|
var profile []byte
|
||||||
return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"}
|
applicable := true
|
||||||
|
for _, intent := range outputIntents {
|
||||||
|
if intent.Subtype != "GTS_PDFA1" && intent.Subtype != "GTS_PDFX" || intent.Space == nil || len(intent.Profile) == 0 || profile != nil && !bytes.Equal(profile, intent.Profile) {
|
||||||
|
applicable = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
profile = intent.Profile
|
||||||
|
}
|
||||||
|
if applicable {
|
||||||
|
if err := reader.SetDeviceColorProfile(profile); err != nil {
|
||||||
|
return nil, fmt.Errorf("import PDF device color profile: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if options.Strict {
|
||||||
|
return nil, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"}
|
||||||
|
}
|
||||||
|
importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"})
|
||||||
}
|
}
|
||||||
importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"})
|
|
||||||
}
|
}
|
||||||
importer.pageIndexes = make(map[pdfgo.Reference]int)
|
importer.pageIndexes = make(map[pdfgo.Reference]int)
|
||||||
importer.resolveFile = options.ResolveFile
|
importer.resolveFile = options.ResolveFile
|
||||||
@@ -281,10 +340,14 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
importer.halftones = options.Halftones
|
importer.halftones = options.Halftones
|
||||||
importer.compression = options.Compression
|
importer.compression = options.Compression
|
||||||
importer.references = &codecpdf.ReferenceCache{}
|
importer.references = &codecpdf.ReferenceCache{}
|
||||||
defer importer.references.Close()
|
defer func() {
|
||||||
|
if !keep {
|
||||||
|
importer.references.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
importer.halftoneWarnings = make(map[*pdfgo.Halftone]bool)
|
||||||
if err := progress.report("pages", 0, 0); err != nil {
|
if err := progress.report("pages", 0, 0); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
||||||
_, width, height := codecpdf.PageMatrix(page)
|
_, width, height := codecpdf.PageMatrix(page)
|
||||||
@@ -298,82 +361,228 @@ func ImportPDF(ctx context.Context, input *Input, options PDFImportOptions) (*Ed
|
|||||||
return progress.report("pages", index+1, 0)
|
return progress.report("pages", index+1, 0)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(editor.pages) != 0 {
|
if len(editor.pages) != 0 {
|
||||||
importer.page = -1
|
importer.page = -1
|
||||||
if err := importer.documentMetadata(ctx, options.Strict); err != nil {
|
if err := importer.documentMetadata(ctx, options.Strict); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF metadata: %w", err)
|
return nil, fmt.Errorf("import PDF metadata: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.documentAttachments(ctx); err != nil {
|
if err := importer.documentAttachments(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF attachments: %w", err)
|
return nil, fmt.Errorf("import PDF attachments: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.outlines(ctx); err != nil {
|
if err := importer.outlines(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF outlines: %w", err)
|
return nil, fmt.Errorf("import PDF outlines: %w", err)
|
||||||
}
|
}
|
||||||
if err := importer.documentOpenActions(ctx); err != nil {
|
if err := importer.documentOpenActions(ctx); err != nil {
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF document actions: %w", err)
|
return nil, fmt.Errorf("import PDF document actions: %w", err)
|
||||||
}
|
}
|
||||||
importer.page = 0
|
importer.page = 0
|
||||||
}
|
}
|
||||||
if err := progress.report("convert", 0, len(editor.pages)); err != nil {
|
if err := progress.report("convert", 0, len(editor.pages)); err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err = reader.WalkPages(ctx, func(index int, page *pdfgo.Page) error {
|
if len(editor.pages) == 0 {
|
||||||
matrix, width, height := codecpdf.PageMatrix(page)
|
return nil, fmt.Errorf("PDF contains no pages")
|
||||||
importer.matrix = matrix
|
}
|
||||||
importer.page = index
|
keep = true
|
||||||
importer.rasterWarned = false
|
return &PDFImport{input: input, importer: importer, options: options, pages: cloneEditorData(editor.pages), ready: make([]bool, len(editor.pages))}, nil
|
||||||
importer.pageBox = page.CropBox
|
}
|
||||||
importer.pageWidth = width
|
|
||||||
importer.pageHeight = height
|
// ReadAt 读取当前操作需要的数据,不取得输入所有权
|
||||||
importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path)
|
// 入参: data 目标缓冲, offset 文件偏移
|
||||||
importer.compositeCache = nil
|
// 返回: int 读取字节数, error 读取或取消错误
|
||||||
importer.compositeNodes = nil
|
func (r *pdfImportInput) ReadAt(data []byte, offset int64) (int, error) {
|
||||||
importer.compositeSpace = nil
|
if err := r.importer.ctx.Err(); err != nil {
|
||||||
importer.patternCells = nil
|
return 0, err
|
||||||
importer.patternOrigins = nil
|
}
|
||||||
importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject)
|
return r.input.ReadAt(data, offset)
|
||||||
importer.textOrigins = nil
|
}
|
||||||
importer.clipPaths = nil
|
|
||||||
importer.annotationData = nil
|
// PageCount 返回文档页数
|
||||||
importer.annotationBindings = nil
|
// 返回: int 页面总数
|
||||||
if err := importer.pageOpenActions(ctx, page); err != nil {
|
func (p *PDFImport) PageCount() int { return len(p.pages) }
|
||||||
return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
|
|
||||||
|
// Ready 返回指定页是否转换完成
|
||||||
|
// 入参: index 页面索引,从0开始
|
||||||
|
// 返回: bool 页面是否就绪
|
||||||
|
func (p *PDFImport) Ready(index int) bool {
|
||||||
|
return index >= 0 && index < len(p.ready) && p.ready[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEncryption 设置转换文档的输出加密方式,不改变PDF读取凭据
|
||||||
|
// 入参: options 加密选项,nil取消输出加密
|
||||||
|
// 返回: error 加密配置错误
|
||||||
|
func (p *PDFImport) SetEncryption(options *EncryptionOptions) error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
return p.importer.editor.SetEncryption(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertPage 转换指定页面,已完成的页面不重复处理
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引,从0开始
|
||||||
|
// 返回: error 转换或取消错误
|
||||||
|
func (p *PDFImport) ConvertPage(ctx context.Context, index int) (err error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
if p.err != nil {
|
||||||
|
return p.err
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(p.pages) {
|
||||||
|
return fmt.Errorf("invalid page index: %d", index)
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.ready[index] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
p.err = err
|
||||||
}
|
}
|
||||||
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil {
|
}()
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
importer := p.importer
|
||||||
}
|
importer.ctx = importer.resources.context(ctx)
|
||||||
if err := importer.annotations(ctx, page, options.Strict); err != nil {
|
ctx = importer.ctx
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
importer.renderer.OutputContext = ctx
|
||||||
}
|
page := importer.pageOrder[index]
|
||||||
if err := importer.commitAnnotations(); err != nil {
|
matrix, width, height := codecpdf.PageMatrix(page)
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
importer.matrix = matrix
|
||||||
}
|
importer.page = index
|
||||||
if err := importer.commitObjects(); err != nil {
|
importer.rasterWarned = false
|
||||||
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
importer.pageBox = page.CropBox
|
||||||
}
|
importer.pageWidth = width
|
||||||
importer.report.Pages++
|
importer.pageHeight = height
|
||||||
if err := progress.report("convert", index+1, len(editor.pages)); err != nil {
|
importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path)
|
||||||
|
importer.compositeCache = nil
|
||||||
|
importer.compositeNodes = nil
|
||||||
|
importer.compositeSpace = nil
|
||||||
|
importer.patternCells = nil
|
||||||
|
importer.patternOrigins = nil
|
||||||
|
importer.clipTexts = make(map[*pdfgo.TextClip][]TextObject)
|
||||||
|
importer.textOrigins = nil
|
||||||
|
importer.clipPaths = nil
|
||||||
|
importer.annotationData = nil
|
||||||
|
importer.annotationBindings = nil
|
||||||
|
if err := importer.pageOpenActions(ctx, page); err != nil {
|
||||||
|
return fmt.Errorf("import PDF page %d actions: %w", index+1, err)
|
||||||
|
}
|
||||||
|
if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return importer.reader.WalkPage(ctx, page, visitor) }); err != nil {
|
||||||
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
|
}
|
||||||
|
if err := importer.annotations(ctx, page, p.options.Strict); err != nil {
|
||||||
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
|
}
|
||||||
|
if err := importer.commitAnnotations(); err != nil {
|
||||||
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
|
}
|
||||||
|
if err := importer.commitObjects(); err != nil {
|
||||||
|
return fmt.Errorf("import PDF page %d: %w", index+1, err)
|
||||||
|
}
|
||||||
|
importer.report.Pages++
|
||||||
|
if err := editorProgress(p.options.OnProgress).withContext(ctx).report("convert", importer.report.Pages, len(p.pages)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if p.options.Progress != nil {
|
||||||
|
if err := p.options.Progress(importer.report.Pages); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if options.Progress != nil {
|
}
|
||||||
if err := options.Progress(index + 1); err != nil {
|
p.ready[index] = true
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reader 创建指定页的独立阅读快照,其他页仅保留尺寸和标识
|
||||||
|
// 未包含的页面返回ErrIncompleteDocument;index为-1时仅返回索引,完整编辑或导出须使用Finish
|
||||||
|
// 入参: ctx 取消上下文, index 页面索引
|
||||||
|
// 返回: *Reader 阅读快照, error 转换或构建错误
|
||||||
|
func (p *PDFImport) Reader(ctx context.Context, index int) (*Reader, error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil, fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
if p.err != nil {
|
||||||
|
return nil, p.err
|
||||||
|
}
|
||||||
|
if index != -1 {
|
||||||
|
if err := p.ConvertPage(ctx, index); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return ctx.Err()
|
}
|
||||||
})
|
snapshot := *p.importer.editor
|
||||||
|
snapshot.pages = slices.Clone(p.pages)
|
||||||
|
if index >= 0 {
|
||||||
|
snapshot.pages[index] = p.importer.editor.pages[index]
|
||||||
|
}
|
||||||
|
reader, err := snapshot.Reader(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, PDFImportReport{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if importer.report.Pages == 0 {
|
reader.pageReady = make(map[string]bool)
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("PDF contains no pages")
|
if index >= 0 {
|
||||||
|
reader.pageReady[reader.doc.Pages.Page[index].ID] = true
|
||||||
}
|
}
|
||||||
if err := importer.documentStructures(ctx); err != nil {
|
return reader, nil
|
||||||
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
}
|
||||||
|
|
||||||
|
// Finish 转换尚未就绪的页面并补全文档结构,返回独立编辑文档
|
||||||
|
// 入参: ctx 取消上下文
|
||||||
|
// 返回: *Editor 编辑文档, PDFImportReport 转换统计, error 转换错误
|
||||||
|
func (p *PDFImport) Finish(ctx context.Context) (*Editor, PDFImportReport, error) {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil, PDFImportReport{}, fmt.Errorf("PDF import is closed")
|
||||||
}
|
}
|
||||||
return editor, importer.report, nil
|
for index := range p.pages {
|
||||||
|
if err := p.ConvertPage(ctx, index); err != nil {
|
||||||
|
return nil, PDFImportReport{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !p.finished {
|
||||||
|
p.importer.ctx = p.importer.resources.context(ctx)
|
||||||
|
if err := p.importer.documentStructures(p.importer.ctx); err != nil {
|
||||||
|
p.err = err
|
||||||
|
return nil, PDFImportReport{}, fmt.Errorf("import PDF structure: %w", err)
|
||||||
|
}
|
||||||
|
p.finished = true
|
||||||
|
}
|
||||||
|
return p.importer.editor, p.importer.report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset 放弃已转换内容并重建索引,保留文档标识及输出加密策略
|
||||||
|
// 失败时保留原会话,已交付的阅读快照不受影响;输入源仍由调用方持有
|
||||||
|
// 入参: ctx 本次重建的取消上下文
|
||||||
|
// 返回: error 重建错误
|
||||||
|
func (p *PDFImport) Reset(ctx context.Context) error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return fmt.Errorf("PDF import is closed")
|
||||||
|
}
|
||||||
|
next, err := NewPDFImport(ctx, p.input, p.options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next.importer.editor.Info.DocID = p.importer.editor.Info.DocID
|
||||||
|
next.importer.editor.encryption = p.importer.editor.encryption
|
||||||
|
p.Close()
|
||||||
|
*p = *next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放PDF索引、引用缓存和转换状态,不关闭调用方的输入源
|
||||||
|
// 返回: error 关闭错误
|
||||||
|
func (p *PDFImport) Close() error {
|
||||||
|
if p.importer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.importer.references.Close()
|
||||||
|
err := p.importer.reader.Close()
|
||||||
|
p.importer = nil
|
||||||
|
p.pages, p.ready = nil, nil
|
||||||
|
clear(p.options.Password)
|
||||||
|
p.options = PDFImportOptions{}
|
||||||
|
p.input = nil
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标
|
// ConvertPDF 将PDF转换并写入OFD,转换阶段失败时不写入目标
|
||||||
|
|||||||
+21
-13
@@ -15,6 +15,7 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -76,18 +77,22 @@ func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io
|
|||||||
if writer == nil {
|
if writer == nil {
|
||||||
return fmt.Errorf("missing print writer")
|
return fmt.Errorf("missing print writer")
|
||||||
}
|
}
|
||||||
var out bytes.Buffer
|
out := bufio.NewWriter(convertWriter{ctx: ctx, writer: writer, count: new(int64)})
|
||||||
fmt.Fprintf(&out, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><rect width="100%%" height="100%%" fill="white"/>`, side.Width, side.Height, side.Width, side.Height)
|
fmt.Fprintf(out, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><rect width="100%%" height="100%%" fill="white"/>`, side.Width, side.Height, side.Width, side.Height)
|
||||||
if p.options.Raster || p.options.Grayscale {
|
if p.options.Raster || p.options.Grayscale {
|
||||||
img, err := p.renderPrintImage(ctx, index, p.options.DPI)
|
img, err := p.renderPrintImage(ctx, index, p.options.DPI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var data bytes.Buffer
|
fmt.Fprintf(out, `<image width="%g" height="%g" xlink:href="data:image/png;base64,`, side.Width, side.Height)
|
||||||
if err := png.Encode(&data, img); err != nil {
|
encoder := base64.NewEncoder(base64.StdEncoding, out)
|
||||||
|
if err := png.Encode(encoder, img); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&out, `<image width="%g" height="%g" xlink:href="data:image/png;base64,%s"/>`, side.Width, side.Height, base64.StdEncoding.EncodeToString(data.Bytes()))
|
if err := encoder.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.WriteString(`"/>`)
|
||||||
} else {
|
} else {
|
||||||
for i, placement := range side.Placements {
|
for i, placement := range side.Placements {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@@ -97,23 +102,26 @@ func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var data bytes.Buffer
|
c, m := placement.Clip, placement.Transform
|
||||||
if err := p.renderer.RenderToSVG(page, &data); err != nil {
|
fmt.Fprintf(out, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,`, i, c.X, c.Y, c.W, c.H, i, placement.Width, placement.Height, m[0], m[1], m[2], m[3], m[4], m[5])
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, out)
|
||||||
|
if err := p.renderer.RenderToSVG(page, encoder); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c, m := placement.Clip, placement.Transform
|
if err := encoder.Close(); err != nil {
|
||||||
fmt.Fprintf(&out, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,%s"/></g>`, i, c.X, c.Y, c.W, c.H, i, placement.Width, placement.Height, m[0], m[1], m[2], m[3], m[4], m[5], base64.StdEncoding.EncodeToString(data.Bytes()))
|
return err
|
||||||
|
}
|
||||||
|
out.WriteString(`"/></g>`)
|
||||||
}
|
}
|
||||||
for _, line := range p.printLines(side) {
|
for _, line := range p.printLines(side) {
|
||||||
fmt.Fprintf(&out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
|
fmt.Fprintf(out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
|
||||||
}
|
}
|
||||||
if side.Label != "" {
|
if side.Label != "" {
|
||||||
fmt.Fprintf(&out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
|
fmt.Fprintf(out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.WriteString(`</svg>`)
|
out.WriteString(`</svg>`)
|
||||||
_, err = io.Copy(convertWriter{ctx: ctx, writer: writer, count: new(int64)}, &out)
|
return out.Flush()
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置
|
// RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置
|
||||||
|
|||||||
+9
-1
@@ -20,6 +20,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -28,6 +29,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrIncompleteDocument 表示渐进阅读快照尚未包含请求的页面或完整文档
|
||||||
|
var ErrIncompleteDocument = errors.New("document content is incomplete")
|
||||||
|
|
||||||
// Reader OFD文件阅读器
|
// Reader OFD文件阅读器
|
||||||
// Reader及共享它的Renderer需串行调用
|
// Reader及共享它的Renderer需串行调用
|
||||||
type Reader struct {
|
type Reader struct {
|
||||||
@@ -50,6 +54,7 @@ type Reader struct {
|
|||||||
drawParamCache map[string]*DrawParam
|
drawParamCache map[string]*DrawParam
|
||||||
compositeGraphicUnitCache map[string]*CompositeGraphicUnit
|
compositeGraphicUnitCache map[string]*CompositeGraphicUnit
|
||||||
pageHeaderCache map[string]PageContent
|
pageHeaderCache map[string]PageContent
|
||||||
|
pageReady map[string]bool
|
||||||
pageContents *renderCache[pageContentKey, PageContent]
|
pageContents *renderCache[pageContentKey, PageContent]
|
||||||
doc *Document
|
doc *Document
|
||||||
relatedPending bool
|
relatedPending bool
|
||||||
@@ -211,7 +216,7 @@ func (r *Reader) DocumentContext(ctx context.Context, index int) (*Reader, error
|
|||||||
if index < 0 || index >= r.DocumentCount() {
|
if index < 0 || index >= r.DocumentCount() {
|
||||||
return nil, fmt.Errorf("document index %d out of range", index)
|
return nil, fmt.Errorf("document index %d out of range", index)
|
||||||
}
|
}
|
||||||
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources}
|
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index, resources: r.resources, pageReady: r.pageReady}
|
||||||
if err := next.initRootContext(ctx); err != nil {
|
if err := next.initRootContext(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1098,6 +1103,9 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string {
|
|||||||
// 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取
|
// 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) {
|
func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) {
|
||||||
|
if !template && r.pageReady != nil && !r.pageReady[page.ID] {
|
||||||
|
return nil, ErrIncompleteDocument
|
||||||
|
}
|
||||||
fullPath := r.ResPath(page.BaseLoc)
|
fullPath := r.ResPath(page.BaseLoc)
|
||||||
data, err := r.readFileViewContext(ctx, fullPath)
|
data, err := r.readFileViewContext(ctx, fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-1
@@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string
|
|||||||
return false, false, nil, nil
|
return false, false, nil, nil
|
||||||
}
|
}
|
||||||
for _, attr := range token.Attr {
|
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
|
return false, false, nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-5
@@ -31,6 +31,7 @@ import (
|
|||||||
|
|
||||||
// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号
|
// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号
|
||||||
type richLayout struct {
|
type richLayout struct {
|
||||||
|
width float64
|
||||||
objects []GraphicObject
|
objects []GraphicObject
|
||||||
bounds Box
|
bounds Box
|
||||||
anchors []int
|
anchors []int
|
||||||
@@ -91,8 +92,7 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
|
|||||||
return richLayout{}, err
|
return richLayout{}, err
|
||||||
}
|
}
|
||||||
options := value.Layout
|
options := value.Layout
|
||||||
available := width - options.LeftIndent - options.RightIndent
|
if !finite(width) || width <= 0 {
|
||||||
if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 {
|
|
||||||
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
|
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
|
||||||
}
|
}
|
||||||
geometry, err := e.Geometry()
|
geometry, err := e.Geometry()
|
||||||
@@ -124,16 +124,52 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
|
|||||||
}
|
}
|
||||||
chars := []rune(value.Text())
|
chars := []rune(value.Text())
|
||||||
var face *typefont.Face
|
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 underlays []GraphicObject
|
||||||
var shadows []GraphicObject
|
var shadows []GraphicObject
|
||||||
var y float64
|
var y float64
|
||||||
for start := 0; start <= len(chars); {
|
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
|
||||||
end := start
|
end := start
|
||||||
for end < len(chars) && chars[end] != '\n' {
|
for end < len(chars) && chars[end] != '\n' {
|
||||||
end++
|
end++
|
||||||
}
|
}
|
||||||
paragraph := chars[start: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 {
|
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
|
||||||
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
|
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
|
||||||
if err != nil {
|
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)
|
ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height)
|
||||||
}
|
}
|
||||||
height = max(height, ascent+descent)
|
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))
|
copy(steps, parseFloats(delta))
|
||||||
if local.Shaping.Direction == "rtl" {
|
if local.Shaping.Direction == "rtl" {
|
||||||
for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- {
|
for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- {
|
||||||
|
|||||||
+189
-6
@@ -17,6 +17,8 @@ package ofdgo
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -27,9 +29,43 @@ import (
|
|||||||
var ErrNotRichText = errors.New("object is not rich text")
|
var ErrNotRichText = errors.New("object is not rich text")
|
||||||
|
|
||||||
// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节
|
// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节
|
||||||
|
// Paragraphs为空时所有段落沿用Layout,否则数量必须与LF分隔的段落一致
|
||||||
|
// AutoWidth在不折行时按各段自然宽度确定文本框,指定宽度调整后关闭
|
||||||
type RichText struct {
|
type RichText struct {
|
||||||
Runs []RichTextRun `json:"runs"`
|
AutoWidth bool `json:"autoWidth,omitempty"`
|
||||||
Layout TextLayout `json:"layout"`
|
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,长度单位为毫米
|
// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米
|
||||||
@@ -64,6 +100,59 @@ type TextFormatPatch struct {
|
|||||||
Background *string `json:"background,omitempty"`
|
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 返回包含显式换行的原文,不包含排版产生的软换行
|
// Text 返回包含显式换行的原文,不包含排版产生的软换行
|
||||||
// 返回: string 完整原文
|
// 返回: string 完整原文
|
||||||
func (v RichText) Text() string {
|
func (v RichText) Text() string {
|
||||||
@@ -87,6 +176,14 @@ func (v RichText) Validate() error {
|
|||||||
if err := validateRichLayout(v.Layout); err != nil {
|
if err := validateRichLayout(v.Layout); err != nil {
|
||||||
return err
|
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())
|
bounds := richTextBoundaries(v.Text())
|
||||||
position := 0
|
position := 0
|
||||||
for _, run := range v.Runs {
|
for _, run := range v.Runs {
|
||||||
@@ -134,7 +231,7 @@ func validateRichLayout(v TextLayout) error {
|
|||||||
return fmt.Errorf("invalid rich text layout dimensions")
|
return fmt.Errorf("invalid rich text layout dimensions")
|
||||||
}
|
}
|
||||||
switch v.Align {
|
switch v.Align {
|
||||||
case "", "left", "center", "right", "justify":
|
case "", "left", "center", "right", "justify", "distributed":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("invalid rich text alignment")
|
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 {
|
if err := v.checkRange(start, end); err != nil {
|
||||||
return RichText{}, err
|
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 {
|
if len(result.Runs) == 0 {
|
||||||
position := 0
|
position := 0
|
||||||
format := v.Runs[len(v.Runs)-1].Format
|
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 {
|
if err := v.checkRange(start, end); err != nil {
|
||||||
return err
|
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, runs...)
|
||||||
next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...)
|
next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...)
|
||||||
if len(next.Runs) == 0 {
|
if len(next.Runs) == 0 {
|
||||||
caret, _ := v.Slice(start, start)
|
caret, _ := v.Slice(start, start)
|
||||||
next.Runs = caret.Runs
|
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)
|
next = normalizeRichText(next)
|
||||||
if err := next.Validate(); err != nil {
|
if err := next.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -255,7 +373,72 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error {
|
|||||||
if start == end {
|
if start == end {
|
||||||
return nil
|
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 := ¶graphs[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 应用显式给出的格式字段
|
// apply 应用显式给出的格式字段
|
||||||
|
|||||||
+184
-36
@@ -20,11 +20,19 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
"unsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// PageTextCache 按内存预算保留最近访问的页面文字索引
|
||||||
|
// 缓存后不得修改PageText,页面或字体变化时须删除对应条目
|
||||||
|
type PageTextCache struct {
|
||||||
|
cache *renderCache[int, *PageText]
|
||||||
|
}
|
||||||
|
|
||||||
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
|
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
|
||||||
type textPageTokens struct {
|
type textPageTokens struct {
|
||||||
*xml.Decoder
|
*xml.Decoder
|
||||||
@@ -53,16 +61,103 @@ type TextSpan struct {
|
|||||||
Matrix [6]float64 `json:"matrix"`
|
Matrix [6]float64 `json:"matrix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextMatch 文字匹配结果,ID为对象标识,Run为文本对象索引,Start和End为原文Unicode字符区间
|
// TextMatch 文字匹配结果,ID和Run定位首个对象,Start和End为该对象内的Unicode字符区间
|
||||||
|
// 跨对象匹配通过Parts保留各对象区间,Text和Boxes包含完整命中
|
||||||
type TextMatch struct {
|
type TextMatch struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Run int `json:"run"`
|
Run int `json:"run"`
|
||||||
Start int `json:"start"`
|
Start int `json:"start"`
|
||||||
End int `json:"end"`
|
End int `json:"end"`
|
||||||
Before string `json:"before"`
|
Before string `json:"before"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
After string `json:"after"`
|
After string `json:"after"`
|
||||||
Boxes []Box `json:"boxes"`
|
Boxes []Box `json:"boxes"`
|
||||||
|
Parts []TextMatchPart `json:"parts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextMatchPart 跨对象匹配中的原文区间,Start和End为对象内Unicode字符偏移
|
||||||
|
type TextMatchPart struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Run int `json:"run"`
|
||||||
|
Start int `json:"start"`
|
||||||
|
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节点
|
// Token 读取文字页面所需的XML节点
|
||||||
@@ -172,7 +267,7 @@ func (p *PageText) WriteTo(writer io.Writer) (int64, error) {
|
|||||||
return p.writeTo(writer, false)
|
return p.writeTo(writer, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像
|
// Search 按原文进行忽略大小写的字面匹配,拼接同一行紧邻的文本对象,不识别图像
|
||||||
// 入参: query 搜索文字
|
// 入参: query 搜索文字
|
||||||
// 返回: []TextMatch 匹配结果及上下文
|
// 返回: []TextMatch 匹配结果及上下文
|
||||||
func (p *PageText) Search(query string) []TextMatch {
|
func (p *PageText) Search(query string) []TextMatch {
|
||||||
@@ -184,7 +279,7 @@ func (p *PageText) Search(query string) []TextMatch {
|
|||||||
return matches
|
return matches
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchEach 逐项返回忽略大小写的字面匹配,不累积全部结果,在对象与匹配之间检查取消
|
// SearchEach 逐项返回忽略大小写的字面匹配,拼接同一行紧邻对象,在索引构建及匹配之间检查取消
|
||||||
// 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止
|
// 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止
|
||||||
// 返回: error 取消或访问错误
|
// 返回: error 取消或访问错误
|
||||||
func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error {
|
func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error {
|
||||||
@@ -268,11 +363,39 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
length := utf8.RuneCountInString(query)
|
length := utf8.RuneCountInString(query)
|
||||||
for index, run := range p.Runs {
|
for first := 0; first < len(p.Runs); {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
text := foldText(run.Text)
|
last := first + 1
|
||||||
|
for last < len(p.Runs) && adjacentTextRuns(p.Runs[last-1], p.Runs[last]) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
last++
|
||||||
|
}
|
||||||
|
var source strings.Builder
|
||||||
|
offsets := make([]int, last-first+1)
|
||||||
|
for index := first; index < last; index++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source.WriteString(p.Runs[index].Text)
|
||||||
|
offsets[index-first+1] = offsets[index-first] + utf8.RuneCountInString(p.Runs[index].Text)
|
||||||
|
}
|
||||||
|
var folded strings.Builder
|
||||||
|
folded.Grow(source.Len())
|
||||||
|
polled := 0
|
||||||
|
for _, char := range source.String() {
|
||||||
|
if polled%256 == 0 {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
polled++
|
||||||
|
folded.WriteRune(foldRune(char))
|
||||||
|
}
|
||||||
|
text := folded.String()
|
||||||
var runes []rune
|
var runes []rune
|
||||||
codeOffset := 0
|
codeOffset := 0
|
||||||
for offset := 0; offset < len(text); {
|
for offset := 0; offset < len(text); {
|
||||||
@@ -284,21 +407,24 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if runes == nil {
|
if runes == nil {
|
||||||
runes = []rune(run.Text)
|
runes = []rune(source.String())
|
||||||
}
|
}
|
||||||
start := codeOffset + utf8.RuneCountInString(text[offset:offset+found])
|
start := codeOffset + utf8.RuneCountInString(text[offset:offset+found])
|
||||||
offset += found
|
offset += found
|
||||||
end := start + length
|
end := start + length
|
||||||
match := TextMatch{
|
begin := sort.Search(len(offsets)-1, func(i int) bool { return offsets[i+1] > start })
|
||||||
ID: run.ID,
|
match := TextMatch{ID: p.Runs[first+begin].ID, Run: first + begin, Start: start - offsets[begin], End: min(end, offsets[begin+1]) - offsets[begin],
|
||||||
Run: index, Start: start, End: end,
|
Before: string(runes[max(0, start-16):start]), Text: string(runes[start:end]), After: string(runes[end:min(len(runes), end+16)])}
|
||||||
Before: string(runes[max(0, start-16):start]),
|
for index := begin; index < last-first && offsets[index] < end; index++ {
|
||||||
Text: string(runes[start:end]),
|
run := p.Runs[first+index]
|
||||||
After: string(runes[end:min(len(runes), end+16)]),
|
from, to := max(start, offsets[index])-offsets[index], min(end, offsets[index+1])-offsets[index]
|
||||||
}
|
if end > offsets[begin+1] {
|
||||||
for _, box := range run.Boxes[min(start, len(run.Boxes)):min(end, len(run.Boxes))] {
|
match.Parts = append(match.Parts, TextMatchPart{ID: run.ID, Run: first + index, Start: from, End: to})
|
||||||
if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) {
|
}
|
||||||
match.Boxes = append(match.Boxes, box)
|
for _, box := range run.Boxes[min(from, len(run.Boxes)):min(to, len(run.Boxes))] {
|
||||||
|
if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) {
|
||||||
|
match.Boxes = append(match.Boxes, box)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := visit(match); err != nil {
|
if err := visit(match); err != nil {
|
||||||
@@ -307,6 +433,7 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
|
|||||||
offset += len(query)
|
offset += len(query)
|
||||||
codeOffset = end
|
codeOffset = end
|
||||||
}
|
}
|
||||||
|
first = last
|
||||||
}
|
}
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
@@ -315,19 +442,40 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text
|
|||||||
// 入参: text 原文
|
// 入参: text 原文
|
||||||
// 返回: string 匹配用文字
|
// 返回: string 匹配用文字
|
||||||
func foldText(text string) string {
|
func foldText(text string) string {
|
||||||
return strings.Map(func(char rune) rune {
|
return strings.Map(foldRune, text)
|
||||||
if char < utf8.RuneSelf {
|
}
|
||||||
if char >= 'a' && char <= 'z' {
|
|
||||||
return char - ('a' - 'A')
|
// foldRune 统一单个Unicode大小写等价字符
|
||||||
}
|
// 入参: char 原字符
|
||||||
return char
|
// 返回: rune 匹配字符
|
||||||
|
func foldRune(char rune) rune {
|
||||||
|
if char < utf8.RuneSelf {
|
||||||
|
if char >= 'a' && char <= 'z' {
|
||||||
|
return char - ('a' - 'A')
|
||||||
}
|
}
|
||||||
folded := char
|
return char
|
||||||
for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) {
|
}
|
||||||
folded = min(folded, next)
|
folded := char
|
||||||
}
|
for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) {
|
||||||
return folded
|
folded = min(folded, next)
|
||||||
}, text)
|
}
|
||||||
|
return folded
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjacentTextRuns 判断绘制顺序相邻的文字是否在同一行连续,缺少定位时保持独立
|
||||||
|
// 入参: before 前对象, after 后对象
|
||||||
|
// 返回: bool 是否可连续搜索
|
||||||
|
func adjacentTextRuns(before, after TextRun) bool {
|
||||||
|
if before.Text == "" || after.Text == "" || len(before.Boxes) != utf8.RuneCountInString(before.Text) || len(after.Boxes) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a, b := before.Boxes[len(before.Boxes)-1], after.Boxes[0]
|
||||||
|
size := math.Min(a.H, b.H)
|
||||||
|
if size <= 0 || a.W <= 0 || b.W <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
gap := b.X - a.X - a.W
|
||||||
|
return math.Abs(a.Y+a.H/2-b.Y-b.H/2) <= size*.25 && gap >= -size*.15 && gap <= size*.35 && b.X > a.X
|
||||||
}
|
}
|
||||||
|
|
||||||
// addRun 收集原始文本,字形索引不替代Unicode原文
|
// addRun 收集原始文本,字形索引不替代Unicode原文
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import (
|
|||||||
"crypto"
|
"crypto"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -40,6 +42,261 @@ const (
|
|||||||
// SignatureWriteMode 签署方式
|
// SignatureWriteMode 签署方式
|
||||||
type SignatureWriteMode uint8
|
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或调用方的原件
|
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
|
||||||
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
|
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
|
||||||
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容
|
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package ofdgo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -430,6 +431,14 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
*obj = TextObject(value)
|
*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 {
|
for _, attr := range ofdXMLStart(start).Attr {
|
||||||
if attr.Name.Local == "LineWidth" {
|
if attr.Name.Local == "LineWidth" {
|
||||||
obj.LineWidthSet = obj.LineWidth == 0
|
obj.LineWidthSet = obj.LineWidth == 0
|
||||||
|
|||||||
+15
-7
@@ -33,7 +33,7 @@ type configuredTextShaper struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
|
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
|
||||||
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
|
// 不修改文档,通过AddObject或UpdateObject提交;选项随标准文字定位保存,重新打开时恢复
|
||||||
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
|
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) 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) {
|
if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) {
|
||||||
return fmt.Errorf("paragraph indents must be finite")
|
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)
|
return fmt.Errorf("invalid text alignment %q", options.Align)
|
||||||
}
|
}
|
||||||
var width float64
|
var width float64
|
||||||
@@ -129,7 +129,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
|||||||
if i == 0 {
|
if i == 0 {
|
||||||
indent = options.FirstLineIndent
|
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
|
x += options.LeftIndent + indent
|
||||||
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
||||||
if !finite(x) || !finite(y) {
|
if !finite(x) || !finite(y) {
|
||||||
@@ -143,10 +143,11 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
|||||||
}
|
}
|
||||||
obj.TextCode = codes
|
obj.TextCode = codes
|
||||||
obj.CGTransform = nil
|
obj.CGTransform = nil
|
||||||
obj.layout = nil
|
autoWidth := options == (TextLayout{})
|
||||||
if options != (TextLayout{}) {
|
if obj.layout != nil && obj.layout.options == options {
|
||||||
obj.layout = &textLayout{value: value, options: options}
|
autoWidth = obj.layout.autoWidth
|
||||||
}
|
}
|
||||||
|
obj.layout = &textLayout{value: value, options: options, autoWidth: autoWidth}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +221,14 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
chars := runes[line[0]:line[1]]
|
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" {
|
if options.Shaping.Direction == "rtl" {
|
||||||
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
|
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
|
||||||
x -= lineAdvances[j]
|
x -= lineAdvances[j]
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func (r *Reader) DocumentVersion(id string) (*Reader, error) {
|
|||||||
// 入参: ctx 取消上下文, id 版本标识,空值选择主入口
|
// 入参: ctx 取消上下文, id 版本标识,空值选择主入口
|
||||||
// 返回: *Reader 版本阅读器, error 读取或取消错误
|
// 返回: *Reader 版本阅读器, error 读取或取消错误
|
||||||
func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) {
|
func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) {
|
||||||
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id}
|
next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id, pageReady: r.pageReady}
|
||||||
if err := next.initRootContext(ctx); err != nil {
|
if err := next.initRootContext(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,6 +791,14 @@ func (e *Editor) writePartsWithPages(ctx context.Context, write func(string, []b
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editorResourceSelection 保存单次输出中同一编辑快照的资源筛选结果
|
||||||
|
type editorResourceSelection struct {
|
||||||
|
owner *Editor
|
||||||
|
fonts, images []editorResource
|
||||||
|
spaces []ColorSpace
|
||||||
|
files map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
// usedResources 沿图层、对象及资源依赖筛选新增资源和共享文件,不修改资源池
|
// usedResources 沿图层、对象及资源依赖筛选新增资源和共享文件,不修改资源池
|
||||||
// 入参: ctx 取消上下文
|
// 入参: ctx 取消上下文
|
||||||
// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, map[string]bool 独立资源文件集合, error 取消错误
|
// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, map[string]bool 独立资源文件集合, error 取消错误
|
||||||
@@ -798,6 +806,10 @@ func (e *Editor) usedResources(ctx context.Context) (fonts, images []editorResou
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
if e.output != nil && e.output.resources != nil && e.output.resources.owner == e {
|
||||||
|
cached := e.output.resources
|
||||||
|
return slices.Clone(cached.fonts), slices.Clone(cached.images), slices.Clone(cached.spaces), maps.Clone(cached.files), nil
|
||||||
|
}
|
||||||
used := make(map[string]bool)
|
used := make(map[string]bool)
|
||||||
promoted := make(map[string]bool)
|
promoted := make(map[string]bool)
|
||||||
files := make(map[string]bool)
|
files := make(map[string]bool)
|
||||||
@@ -945,6 +957,9 @@ func (e *Editor) usedResources(ctx context.Context) (fonts, images []editorResou
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
if e.output != nil {
|
||||||
|
e.output.resources = &editorResourceSelection{owner: e, fonts: slices.Clone(fonts), images: slices.Clone(images), spaces: slices.Clone(spaces), files: maps.Clone(files)}
|
||||||
|
}
|
||||||
return fonts, images, spaces, files, nil
|
return fonts, images, spaces, files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,6 +1242,15 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject", "Text":
|
case "TextObject", "Text":
|
||||||
obj := object.TextObject
|
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("ID", obj.ID)
|
||||||
attrs.add("DrawParam", obj.DrawParam)
|
attrs.add("DrawParam", obj.DrawParam)
|
||||||
attrs.add("Boundary", obj.Boundary)
|
attrs.add("Boundary", obj.Boundary)
|
||||||
|
|||||||
+4
-1
@@ -93,7 +93,10 @@ func decodeOFDChildren(d *xml.Decoder, start xml.StartElement, decode func(*xml.
|
|||||||
}
|
}
|
||||||
continue
|
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
|
return err
|
||||||
}
|
}
|
||||||
case xml.EndElement:
|
case xml.EndElement:
|
||||||
|
|||||||
Reference in new issue
Block a user