mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 08:46:55 +08:00
Compare commits
37
Commits
20374f52f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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)"
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<option value="left">左齐</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右齐</option>
|
||||
<option value="justify">两端</option>
|
||||
<option value="justify">两端</option><option value="distributed">分散</option>
|
||||
</select>
|
||||
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" title="行距,0为自动" disabled><span>pt</span></span></label>
|
||||
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
|
||||
@@ -876,7 +876,7 @@
|
||||
<section id="signaturePanel" class="signature-panel" hidden>
|
||||
<div class="panel-row">
|
||||
<div id="signaturePanelTitle" class="panel-title compact">签名</div>
|
||||
<span id="signatureSummary" class="count-pill">0</span>
|
||||
<div class="panel-actions"><span id="signatureSummary" class="count-pill">0</span><button id="signatureClear" class="small-button" type="button" hidden>清空</button></div>
|
||||
</div>
|
||||
<div id="signatureList" class="signature-list"></div>
|
||||
</section>
|
||||
|
||||
@@ -3262,6 +3262,12 @@ button.outline-link:hover {
|
||||
text-indent: inherit;
|
||||
}
|
||||
|
||||
.edit-text div::after,
|
||||
.edit-text p::after {
|
||||
content: "";
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.edit-text span[data-glyph] {
|
||||
position: relative;
|
||||
-webkit-text-fill-color: transparent;
|
||||
|
||||
+724
-268
File diff suppressed because it is too large.
Load diff
+487
-143
@@ -11,6 +11,10 @@ function sourceTextScale(editing) {
|
||||
return Math.max(Math.hypot(a, b), Math.hypot(c, d)) || 1;
|
||||
}
|
||||
|
||||
function textFrameBox(item) {
|
||||
return { x: 0, y: 0, width: item.paragraphWidth ?? item.textFrame.width, height: item.textFrame.height };
|
||||
}
|
||||
|
||||
export function fontFaceStyle(data) {
|
||||
const style = { weight: "400", style: "normal" };
|
||||
const bytes = ArrayBuffer.isView(data) ? new DataView(data.buffer, data.byteOffset, data.byteLength) : new DataView(data);
|
||||
@@ -42,6 +46,87 @@ export function richTextModel(item) {
|
||||
firstLineIndent: item.firstLineIndent || 0, autoBidi: true } };
|
||||
}
|
||||
|
||||
function paragraphRange(text, start, end) {
|
||||
const first = text.slice(0, start).split("\n").length - 1;
|
||||
return [first, end > start ? text.slice(0, end - 1).split("\n").length - 1 : first];
|
||||
}
|
||||
|
||||
export function richTextAlignment(model, start = 0, end = model.runs.reduce((n, run) => n + run.text.length, 0)) {
|
||||
const text = model.runs.map(run => run.text).join("");
|
||||
const [first, last] = paragraphRange(text, start, end);
|
||||
const align = model.paragraphs?.[first]?.align || model.layout.align || "left";
|
||||
for (let i = first + 1; i <= last; i++) if ((model.paragraphs?.[i]?.align || model.layout.align || "left") !== align) return "";
|
||||
return align;
|
||||
}
|
||||
|
||||
export function richTextLayout(model, start = 0, end = model.runs.reduce((n, run) => n + run.text.length, 0)) {
|
||||
const [first, last] = paragraphRange(model.runs.map(run => run.text).join(""), start, end);
|
||||
const value = { ...model.layout, ...model.paragraphs?.[first] };
|
||||
for (const key of ["lineHeight", "letterSpacing", "leftIndent", "rightIndent", "firstLineIndent"]) {
|
||||
value[key] ??= 0;
|
||||
for (let i = first + 1; i <= last; i++) if ((model.paragraphs?.[i]?.[key] ?? model.layout[key] ?? 0) !== value[key]) value[key] = undefined;
|
||||
}
|
||||
value.align = richTextAlignment(model, start, end);
|
||||
return value;
|
||||
}
|
||||
|
||||
function replaceParagraphs(model, text, start, end, inserted, paragraphs) {
|
||||
if (!model.paragraphs?.length && !paragraphs?.length) return;
|
||||
const first = text.slice(0, start).split("\n").length - 1, last = text.slice(0, end).split("\n").length - 1;
|
||||
const current = model.paragraphs?.length ? model.paragraphs : text.split("\n").map(() => ({ align: "" }));
|
||||
model.paragraphs = [...current.slice(0, first),
|
||||
...inserted.split("\n").map((_, i) => ({ ...(paragraphs?.[i] || current[first]) })), ...current.slice(last + 1)];
|
||||
}
|
||||
|
||||
function distributeParagraph(input, paragraph) {
|
||||
const probe = input.cloneNode(false), copy = paragraph.cloneNode(true);
|
||||
Object.assign(probe.style, { position: "fixed", transform: "none", visibility: "hidden", pointerEvents: "none", width: `${input.offsetWidth}px` });
|
||||
probe.contentEditable = "false";
|
||||
probe.append(copy);
|
||||
document.body.append(probe);
|
||||
try {
|
||||
const lines = [], range = document.createRange(), width = copy.clientWidth;
|
||||
const spacing = parseFloat(getComputedStyle(copy).letterSpacing) || 0;
|
||||
const indent = parseFloat(getComputedStyle(copy).textIndent) || 0;
|
||||
for (const span of copy.children) {
|
||||
const node = span.firstChild;
|
||||
if (node?.nodeType !== 3) continue;
|
||||
for (const part of graphemes.segment(node.textContent)) {
|
||||
range.setStart(node, part.index); range.setEnd(node, part.index + part.segment.length);
|
||||
const rect = range.getBoundingClientRect();
|
||||
let line = lines.at(-1);
|
||||
if (!line || rect.top >= line.bottom - 0.5 || rect.bottom <= line.top + 0.5) {
|
||||
line = { top: rect.top, bottom: rect.bottom, parts: [] }; lines.push(line);
|
||||
}
|
||||
line.parts.push({ text: part.segment, width: rect.width, style: span.style.cssText });
|
||||
}
|
||||
}
|
||||
if (!lines.length) return;
|
||||
paragraph.replaceChildren();
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (index) { const wrap = document.createElement("br"); wrap.dataset.textWrap = "true"; paragraph.append(wrap); }
|
||||
const row = document.createElement("span");
|
||||
row.style.whiteSpace = "pre";
|
||||
let end = line.parts.length;
|
||||
while (end && line.parts[end - 1].text === " ") end--;
|
||||
const length = line.parts.slice(0, end).reduce((sum, part) => sum + part.width, 0) - (end ? spacing : 0);
|
||||
const extra = end > 1 ? Math.max(0, width - (index ? 0 : indent) - length) / (end - 1) : 0;
|
||||
let previous;
|
||||
for (const [i, part] of line.parts.entries()) {
|
||||
const tracking = i < end - 1 ? spacing + extra : i === end - 1 ? 0 : spacing;
|
||||
const key = `${part.style}:${tracking}`;
|
||||
if (previous?.key === key) previous.node.firstChild.appendData(part.text);
|
||||
else {
|
||||
const span = document.createElement("span"); span.style.cssText = part.style;
|
||||
span.style.letterSpacing = `${tracking}px`; span.textContent = part.text; row.append(span);
|
||||
previous = {key, node:span};
|
||||
}
|
||||
}
|
||||
paragraph.append(row);
|
||||
}
|
||||
} finally { probe.remove(); }
|
||||
}
|
||||
|
||||
function normalizeRuns(runs) {
|
||||
const result = [];
|
||||
for (const run of runs) {
|
||||
@@ -71,21 +156,50 @@ function sliceRuns(runs, start, end) {
|
||||
});
|
||||
}
|
||||
|
||||
function textChange(before, after) {
|
||||
const sameFormat = (a, b) => a && b && FORMAT_KEYS.every(key => a.format[key] === b.format[key]);
|
||||
let first = 0, lastBefore = before.length - 1, lastAfter = after.length - 1, start = 0;
|
||||
let endBefore = before.reduce((length, run) => length + run.text.length, 0);
|
||||
let endAfter = after.reduce((length, run) => length + run.text.length, 0);
|
||||
while (sameFormat(before[first], after[first]) && before[first].text === after[first].text) start += before[first++].text.length;
|
||||
while (lastBefore >= first && lastAfter >= first && sameFormat(before[lastBefore], after[lastAfter]) && before[lastBefore].text === after[lastAfter].text) {
|
||||
endBefore -= before[lastBefore--].text.length;
|
||||
endAfter -= after[lastAfter--].text.length;
|
||||
}
|
||||
if (first <= lastBefore && first <= lastAfter && sameFormat(before[first], after[first])) {
|
||||
const left = before[first].text, right = after[first].text;
|
||||
let count = 0;
|
||||
while (count < left.length && count < right.length && left[count] === right[count]) count++;
|
||||
start += count;
|
||||
}
|
||||
if (first <= lastBefore && first <= lastAfter && sameFormat(before[lastBefore], after[lastAfter])) {
|
||||
const left = before[lastBefore].text, right = after[lastAfter].text;
|
||||
let count = 0;
|
||||
while (count < Math.min(left.length, right.length, endBefore - start, endAfter - start) && left[left.length - count - 1] === right[right.length - count - 1]) count++;
|
||||
endBefore -= count;
|
||||
endAfter -= count;
|
||||
}
|
||||
return { start, before: sliceRuns(before, start, endBefore), after: sliceRuns(after, start, endAfter) };
|
||||
}
|
||||
|
||||
export class RichTextSession {
|
||||
constructor(editor, model, faces = new Map(), imports = []) {
|
||||
constructor(editor, model, faces = new Map(), imports = [], resources = new Map()) {
|
||||
this.editor = editor;
|
||||
this.editing = editor.input;
|
||||
this.model = structuredClone(model);
|
||||
this.model.runs = normalizeRuns(model.runs);
|
||||
this.initial = JSON.stringify(this.model);
|
||||
this.recordedParagraphs = this.model.paragraphs;
|
||||
this.recordedLayout = this.model.layout;
|
||||
this.faces = faces;
|
||||
for (const face of faces.values()) document.fonts.add(face);
|
||||
this.imports = new Map(imports.map(font => [font.key, font.data]));
|
||||
this.resources = resources;
|
||||
this.choices = new Map();
|
||||
if (this.editing.item.fontChoice && imports.length === 1) this.choices.set(imports[0].key, this.editing.item.fontChoice);
|
||||
this.typing = { ...(model.runs[0]?.format || richTextModel(this.editing.item).runs[0].format) };
|
||||
this.caret = [0, 0];
|
||||
this.entries = [this.snapshot()];
|
||||
this.entries = [{ typing: { ...this.typing }, caret: [...this.caret] }];
|
||||
this.index = 0;
|
||||
this.selectionListener = () => {
|
||||
if (editor.input !== this.editing || this.rendering || this.editing.composing) return;
|
||||
@@ -122,24 +236,46 @@ export class RichTextSession {
|
||||
const runs = sliceRuns(this.model.runs, start, end);
|
||||
return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined]));
|
||||
}
|
||||
snapshot() { return { model: structuredClone(this.model), typing: { ...this.typing }, caret: [...this.caret] }; }
|
||||
selectedRuns() {
|
||||
const [start, end] = this.selection();
|
||||
return start === end ? [{ text: "", format: { ...this.typing } }] : sliceRuns(this.model.runs, start, end);
|
||||
}
|
||||
remember() {
|
||||
this.selection();
|
||||
this.entries[this.index].caret = [...this.caret];
|
||||
}
|
||||
record() {
|
||||
const entry = this.snapshot(), old = this.entries[this.index];
|
||||
const changed = JSON.stringify(entry.model) !== JSON.stringify(old.model);
|
||||
if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return;
|
||||
record(before) {
|
||||
const change = textChange(before, this.model.runs);
|
||||
const entry = { change, typing: { ...this.typing }, caret: [...this.caret] }, old = this.entries[this.index];
|
||||
const paragraphsChanged = JSON.stringify(this.recordedParagraphs) !== JSON.stringify(this.model.paragraphs);
|
||||
const layoutChanged = JSON.stringify(this.recordedLayout) !== JSON.stringify(this.model.layout);
|
||||
if (layoutChanged) entry.layout = { before: this.recordedLayout, after: this.model.layout };
|
||||
this.recordedLayout = this.model.layout;
|
||||
if (paragraphsChanged) entry.paragraphs = { before: this.recordedParagraphs, after: this.model.paragraphs };
|
||||
this.recordedParagraphs = this.model.paragraphs;
|
||||
const changed = change.before.length || change.after.length;
|
||||
if (!changed && !paragraphsChanged && !layoutChanged && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return;
|
||||
if (!changed) entry.change = null;
|
||||
this.entries.splice(++this.index, Infinity, entry);
|
||||
if (changed) this.preview();
|
||||
if (changed || paragraphsChanged || layoutChanged) this.preview();
|
||||
}
|
||||
undo(direction) {
|
||||
if (this.editing.composing || this.editing.saving) return;
|
||||
const entry = this.entries[this.index + direction];
|
||||
if (!entry) return;
|
||||
const change = (direction < 0 ? this.entries[this.index] : entry).change;
|
||||
if (change) {
|
||||
const removed = direction < 0 ? change.after : change.before, added = direction < 0 ? change.before : change.after;
|
||||
const end = change.start + removed.reduce((length, run) => length + run.text.length, 0);
|
||||
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, change.start), ...added, ...sliceRuns(this.model.runs, end, this.text.length)]);
|
||||
}
|
||||
const paragraphs = (direction < 0 ? this.entries[this.index] : entry).paragraphs;
|
||||
if (paragraphs) this.model.paragraphs = direction < 0 ? paragraphs.before : paragraphs.after;
|
||||
const layout = (direction < 0 ? this.entries[this.index] : entry).layout;
|
||||
if (layout) this.model.layout = direction < 0 ? layout.before : layout.after;
|
||||
this.recordedLayout = this.model.layout;
|
||||
this.recordedParagraphs = this.model.paragraphs;
|
||||
this.index += direction;
|
||||
this.model = structuredClone(entry.model);
|
||||
this.typing = { ...entry.typing };
|
||||
this.caret = [...entry.caret];
|
||||
this.render();
|
||||
@@ -154,6 +290,7 @@ export class RichTextSession {
|
||||
const editing = this.editing;
|
||||
if (this.editor.input !== editing || editing.composing || editing.saving) return;
|
||||
const current = () => this.editor.input === editing && this.previewSequence === sequence && !editing.composing && !editing.saving;
|
||||
if (!this.model.runs.length) { this.editor.setTextError(null); return; }
|
||||
try {
|
||||
await this.editor.options.onPreviewRichText(editing.item, this.model, editing.frame.width, this.fonts);
|
||||
if (!current()) return;
|
||||
@@ -168,25 +305,43 @@ export class RichTextSession {
|
||||
if (this.editing.composing || this.editing.saving) return;
|
||||
this.remember();
|
||||
const [start, end] = this.selection();
|
||||
const before = this.model.runs;
|
||||
const apply = format => typeof values === "function" ? values(format) : { ...format, ...values };
|
||||
this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end];
|
||||
if (start !== end) this.model.runs = normalizeRuns([
|
||||
...sliceRuns(this.model.runs, 0, start),
|
||||
...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: { ...run.format, ...values } })),
|
||||
...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: apply(run.format) })),
|
||||
...sliceRuns(this.model.runs, end, this.text.length),
|
||||
]);
|
||||
this.typing = { ...this.typing, ...values };
|
||||
this.record();
|
||||
this.typing = apply(this.typing);
|
||||
this.record(before);
|
||||
this.render();
|
||||
this.editor.options.onTextChange();
|
||||
}
|
||||
replace(runs) {
|
||||
align(value) {
|
||||
this.paragraph({ align: value });
|
||||
}
|
||||
paragraph(values) {
|
||||
if (this.editing.composing || this.editing.saving) return;
|
||||
this.remember();
|
||||
const [start, end] = this.selection();
|
||||
const [start, end] = this.selection(), [first, last] = paragraphRange(this.text, start, end);
|
||||
const { wrap, ...patch } = values;
|
||||
if (wrap !== undefined) this.model.layout = { ...this.model.layout, wrap };
|
||||
if (Object.keys(patch).length) this.model.paragraphs = this.text.split("\n").map((_, i) => ({ align: "", ...this.model.paragraphs?.[i], ...(i >= first && i <= last ? patch : {}) }));
|
||||
this.record(this.model.runs);
|
||||
this.render();
|
||||
this.editor.options.onTextChange();
|
||||
}
|
||||
replace(runs, paragraphs, range) {
|
||||
this.remember();
|
||||
const [start, end] = range || this.selection();
|
||||
const before = this.model.runs;
|
||||
replaceParagraphs(this.model, this.text, start, end, runs.map(run => run.text).join(""), paragraphs);
|
||||
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]);
|
||||
this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)];
|
||||
this.caret.push(this.caret[0]);
|
||||
if (runs.length) this.typing = { ...runs.at(-1).format };
|
||||
this.record();
|
||||
this.record(before);
|
||||
this.render();
|
||||
this.editor.options.onTextChange();
|
||||
}
|
||||
@@ -206,11 +361,13 @@ export class RichTextSession {
|
||||
while (end > start && nextEnd > start && previous[end - 1] === value[nextEnd - 1]) { end--; nextEnd--; }
|
||||
if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; }
|
||||
}
|
||||
const runs = this.model.runs;
|
||||
replaceParagraphs(this.model, previous, start, end, value.slice(start, nextEnd));
|
||||
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start),
|
||||
{ text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]);
|
||||
this.caret = caret;
|
||||
this.beforeCaret = null;
|
||||
this.record();
|
||||
this.record(runs);
|
||||
this.render();
|
||||
}
|
||||
copy(event, cut = false) {
|
||||
@@ -218,65 +375,118 @@ export class RichTextSession {
|
||||
const [start, end] = this.selection();
|
||||
if (start === end || !event.clipboardData) return;
|
||||
const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join("");
|
||||
this.editor.richClipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(),
|
||||
fonts: new Map(this.imports), faces: new Map(this.faces), choices: new Map(this.choices) };
|
||||
const keys = new Set(runs.map(run => run.format.font));
|
||||
const fonts = new Map([...keys].map(key => [key, this.imports.get(key) || this.resources.get(key)]).filter(([, data]) => data));
|
||||
const first = this.text.slice(0, start).split("\n").length - 1;
|
||||
const paragraphs = text.split("\n").map((_, i) => {
|
||||
const layout = { ...this.model.layout, ...this.model.paragraphs?.[first + i] };
|
||||
return Object.fromEntries(["align", "lineHeight", "letterSpacing", "leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, key === "align" ? layout[key] || this.model.layout.align || "left" : layout[key] || 0]));
|
||||
});
|
||||
const clipboard = { token: crypto.randomUUID(), runs, text, paragraphs, scope: this.editor.options.textClipboardScope?.(),
|
||||
fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) };
|
||||
if (this.editor.options.setTextClipboard) this.editor.options.setTextClipboard(clipboard);
|
||||
else this.editor.richClipboard = clipboard;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", text);
|
||||
event.clipboardData.setData(RICH_CLIPBOARD, this.editor.richClipboard.token);
|
||||
event.clipboardData.setData(RICH_CLIPBOARD, clipboard.token);
|
||||
if (cut) this.replace([]);
|
||||
}
|
||||
paste(event) {
|
||||
async paste(event) {
|
||||
event.preventDefault();
|
||||
if (this.editing.composing || this.editing.saving) return;
|
||||
const text = event.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n");
|
||||
const copy = this.editor.richClipboard;
|
||||
const internal = copy && copy.scope === this.editor.options.textClipboardScope?.() && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text;
|
||||
const copy = this.editor.options.getTextClipboard ? this.editor.options.getTextClipboard() : this.editor.richClipboard;
|
||||
const sameScope = copy?.scope === this.editor.options.textClipboardScope?.();
|
||||
const internal = copy && (sameScope || copy.runs.every(run => copy.fonts.has(run.format.font)))
|
||||
&& event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text;
|
||||
let runs;
|
||||
if (internal) {
|
||||
for (const [key, data] of copy.fonts) this.imports.set(key, data);
|
||||
for (const [key, face] of copy.faces) { this.faces.set(key, face); document.fonts.add(face); }
|
||||
this.choices = new Map([...copy.choices, ...(this.choices || [])]);
|
||||
const keys = new Map(copy.runs.map(run => [run.format.font, sameScope ? run.format.font : `temp-font-${crypto.randomUUID()}`]));
|
||||
let faces = copy.faces;
|
||||
if (!sameScope) {
|
||||
const original = this.model.runs, paragraphs = this.model.paragraphs, caret = this.selection();
|
||||
faces = new Map(await Promise.all([...copy.fonts].map(async ([key, data]) =>
|
||||
[key, await new FontFace(`ofdgo-paste-${keys.get(key)}`, data, fontFaceStyle(data)).load()])));
|
||||
if (this.editor.input !== this.editing || this.editing.composing || this.editing.saving || this.model.runs !== original || this.model.paragraphs !== paragraphs || this.selection().join() !== caret.join()) return;
|
||||
}
|
||||
for (const [key, data] of copy.fonts) this.imports.set(keys.get(key), data);
|
||||
for (const [key, face] of faces) if (!this.faces.has(keys.get(key))) { this.faces.set(keys.get(key), face); document.fonts.add(face); }
|
||||
for (const [key, choice] of copy.choices) this.choices.set(keys.get(key), choice);
|
||||
runs = copy.runs.map(run => ({ text: run.text, format: { ...run.format, font: keys.get(run.format.font) } }));
|
||||
}
|
||||
this.replace(internal ? structuredClone(copy.runs) : [{ text, format: { ...this.typing } }]);
|
||||
this.replace(runs || [{ text, format: { ...this.typing } }], internal ? copy.paragraphs : null);
|
||||
}
|
||||
setFont(face, data, key) {
|
||||
key ||= `temp-font-${crypto.randomUUID()}`;
|
||||
const previous = this.faces.get(key);
|
||||
this.faces.set(key, face);
|
||||
if (previous && ![...this.faces.values()].includes(previous)) document.fonts.delete(previous);
|
||||
if (data) this.imports.set(key, data);
|
||||
document.fonts.add(face);
|
||||
this.apply({ font: key });
|
||||
}
|
||||
render() {
|
||||
this.rendering = true;
|
||||
const input = this.editing.input, fragment = document.createDocumentFragment(), positions = [];
|
||||
let paragraph = document.createElement("div"), offset = 0;
|
||||
fragment.append(paragraph);
|
||||
const input = this.editing.input, layout = this.model.layout, lines = [[]], positions = [], distributed = [];
|
||||
const autoWidth = this.model.autoWidth && !layout.wrap;
|
||||
Object.assign(input.style, { whiteSpace: layout.wrap ? "pre-wrap" : "pre",
|
||||
lineHeight: layout.lineHeight ? `${layout.lineHeight * PX_PER_MM}px` : "normal",
|
||||
textIndent: `${(layout.firstLineIndent || 0) * PX_PER_MM}px`, letterSpacing: `${(layout.letterSpacing || 0) * PX_PER_MM}px` });
|
||||
for (const run of this.model.runs) {
|
||||
const pieces = run.text.split("\n"), format = run.format;
|
||||
const pieces = run.text.split("\n");
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
if (i) { paragraph = document.createElement("div"); fragment.append(paragraph); offset++; }
|
||||
const span = document.createElement("span"), node = document.createTextNode(pieces[i]);
|
||||
const face = this.faces.get(format.font) || this.editing.face;
|
||||
Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color,
|
||||
fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal",
|
||||
textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none",
|
||||
textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" });
|
||||
span.append(node); paragraph.append(span);
|
||||
positions.push({ node, start: offset, end: offset + pieces[i].length });
|
||||
offset += pieces[i].length;
|
||||
if (i) lines.push([]);
|
||||
lines.at(-1).push({ text: pieces[i], format: run.format });
|
||||
}
|
||||
}
|
||||
if (!positions.length) {
|
||||
const node = document.createTextNode(""); paragraph.append(node);
|
||||
positions.push({ node, start: 0, end: 0 });
|
||||
if (!lines[0].length) lines[0].push({ text: "", format: this.typing });
|
||||
this.editor.fitTextInput();
|
||||
const previous = this.renderedLines || [];
|
||||
let offset = 0;
|
||||
this.renderedLines = lines.map((parts, index) => {
|
||||
const layout = { ...this.model.layout, ...this.model.paragraphs?.[index] };
|
||||
const align = layout.align || this.model.layout.align || "left";
|
||||
const distribute = align === "distributed" && !CSS.supports("text-justify", "inter-character");
|
||||
const key = JSON.stringify([parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family]), align, layout, distribute ? input.offsetWidth : null]);
|
||||
let paragraph = (!autoWidth || !distribute) && previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null;
|
||||
if (!paragraph) {
|
||||
paragraph = document.createElement("div");
|
||||
for (const {text, format} of parts) {
|
||||
const span = document.createElement("span"), face = this.faces.get(format.font) || this.editing.face;
|
||||
Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color,
|
||||
fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal",
|
||||
textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none",
|
||||
textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" });
|
||||
span.append(document.createTextNode(text));
|
||||
paragraph.append(span);
|
||||
}
|
||||
if (!paragraph.textContent) {
|
||||
paragraph.style.fontSize = paragraph.firstElementChild.style.fontSize;
|
||||
paragraph.append(document.createElement("br"));
|
||||
}
|
||||
Object.assign(paragraph.style, { minWidth: layout.wrap ? "" : "max-content", textAlign: distribute ? "left" : align === "distributed" ? "justify" : align,
|
||||
marginLeft: `${(layout.leftIndent || 0) * PX_PER_MM}px`, marginRight: `${(layout.rightIndent || 0) * PX_PER_MM}px`,
|
||||
lineHeight: layout.lineHeight ? `${layout.lineHeight * PX_PER_MM}px` : "normal", letterSpacing: `${(layout.letterSpacing || 0) * PX_PER_MM}px`, textIndent: `${(layout.firstLineIndent || 0) * PX_PER_MM}px`,
|
||||
textAlignLast: !distribute && align === "distributed" ? "justify" : "auto", textJustify: align === "distributed" ? "inter-character" : "auto" });
|
||||
}
|
||||
if (distribute && (autoWidth || previous[index]?.node !== paragraph)) distributed.push(paragraph);
|
||||
if (input.childNodes[index] !== paragraph) input.insertBefore(paragraph, input.childNodes[index] || null);
|
||||
return { key, node: paragraph };
|
||||
});
|
||||
while (input.childNodes.length > lines.length) input.lastChild.remove();
|
||||
if (autoWidth) input.style.width = getComputedStyle(input).width;
|
||||
for (const paragraph of distributed) distributeParagraph(input, paragraph);
|
||||
for (const [index, paragraph] of [...input.children].entries()) {
|
||||
if (index) offset++;
|
||||
const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
positions.push({ node, start: offset, end: offset + node.textContent.length });
|
||||
offset += node.textContent.length;
|
||||
}
|
||||
}
|
||||
input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`;
|
||||
input.style.caretColor = this.typing.color || "#000000";
|
||||
for (const line of fragment.children) if (!line.textContent) {
|
||||
line.style.fontSize = line.querySelector("span")?.style.fontSize || `${this.typing.size * PX_PER_MM}px`;
|
||||
line.append(document.createElement("br"));
|
||||
}
|
||||
input.replaceChildren(fragment);
|
||||
this.editor.fitTextInput();
|
||||
this.editing.range = null;
|
||||
input.focus({ preventScroll: true });
|
||||
restoreTextSelection(positions, this.caret);
|
||||
@@ -292,6 +502,7 @@ export class RichTextSession {
|
||||
export function editTextValue(input) {
|
||||
let text = "", previous;
|
||||
for (const node of input.childNodes) {
|
||||
if (node.nodeName === "BR" && node.dataset.textWrap) continue;
|
||||
const block = node.nodeName === "DIV" || node.nodeName === "P";
|
||||
if (previous && (block || previous.nodeName === "DIV" || previous.nodeName === "P") && previous.nodeName !== "BR") text += "\n";
|
||||
text += node.nodeType === 3 ? node.textContent : node.nodeName === "BR" ? (node.nextSibling ? "\n" : "") : editTextValue(node);
|
||||
@@ -372,9 +583,12 @@ function arrowPath(shape, box) {
|
||||
}
|
||||
|
||||
export function selectionBounds(items) {
|
||||
const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y));
|
||||
return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x,
|
||||
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
||||
let x = Infinity, y = Infinity, right = -Infinity, bottom = -Infinity;
|
||||
for (const item of items) {
|
||||
x = Math.min(x, item.x); y = Math.min(y, item.y);
|
||||
right = Math.max(right, item.x + item.width); bottom = Math.max(bottom, item.y + item.height);
|
||||
}
|
||||
return { x, y, width: right - x, height: bottom - y };
|
||||
}
|
||||
|
||||
function resizeCorner(node, event) {
|
||||
@@ -392,28 +606,47 @@ function resizeCorner(node, event) {
|
||||
return corner;
|
||||
}
|
||||
|
||||
function alignmentSnap(box, targets, tolerance) {
|
||||
const lines = [];
|
||||
const offset = { x: 0, y: 0 };
|
||||
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
|
||||
let distance = Infinity, line;
|
||||
for (const target of targets) {
|
||||
for (const anchor of [0, 0.5, 1]) {
|
||||
const position = target[axis] + target[size] * anchor;
|
||||
for (const edge of [0, 0.5, 1]) {
|
||||
const delta = position - box[axis] - box[size] * edge;
|
||||
if (Math.abs(delta) > tolerance[axis] || Math.abs(delta) >= distance) continue;
|
||||
distance = Math.abs(delta);
|
||||
export class AlignmentIndex {
|
||||
constructor(targets) {
|
||||
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
|
||||
this[axis] = targets.flatMap((target, index) => [0, 0.5, 1].map((anchor, order) => ({
|
||||
position: target[axis] + target[size] * anchor, target, order: index * 3 + order,
|
||||
}))).sort((a, b) => a.position - b.position || a.order - b.order);
|
||||
}
|
||||
}
|
||||
|
||||
snap(box, tolerance) {
|
||||
const lines = [], offset = { x: 0, y: 0 };
|
||||
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
|
||||
const anchors = this[axis];
|
||||
let best, distance = Infinity, rank = Infinity;
|
||||
for (let edge = 0; edge < 3; edge++) {
|
||||
const value = box[axis] + box[size] * edge / 2;
|
||||
let low = 0, high = anchors.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (anchors[middle].position < value - tolerance[axis]) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
for (let i = low; i < anchors.length && anchors[i].position <= value + tolerance[axis]; i++) {
|
||||
const anchor = anchors[i], delta = anchor.position - box[axis] - box[size] * edge / 2;
|
||||
const next = Math.abs(delta), order = anchor.order * 3 + edge;
|
||||
if (next > tolerance[axis] || next > distance || next === distance && order >= rank) continue;
|
||||
best = anchor;
|
||||
distance = next;
|
||||
rank = order;
|
||||
offset[axis] = delta;
|
||||
const from = Math.min(box[cross], target[cross]);
|
||||
const to = Math.max(box[cross] + box[span], target[cross] + target[span]);
|
||||
line = axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const { position, target } = best;
|
||||
const from = Math.min(box[cross], target[cross]);
|
||||
const to = Math.max(box[cross] + box[span], target[cross] + target[span]);
|
||||
lines.push(axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`);
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
return { ...offset, path: lines.join("") };
|
||||
}
|
||||
return { ...offset, path: lines.join("") };
|
||||
}
|
||||
|
||||
function transformedBox(box, matrix) {
|
||||
@@ -523,13 +756,67 @@ export function selectedText(item) {
|
||||
if (!item?.items) return item?.type === "TextObject" ? item : null;
|
||||
if (!item.items.every(member => member.type === "TextObject")) return null;
|
||||
const text = { ...item, type: "TextObject" };
|
||||
for (const key of ["font", "fontName", "size", "color"]) {
|
||||
const value = item.items[0][key];
|
||||
text[key] = item.items.every(member => member[key] === value) ? value : undefined;
|
||||
const formats = item.items.flatMap(member => member.richText ? member.richText.runs.map(run => run.format) : [{ ...member, ...richTextModel(member).runs[0].format }]);
|
||||
for (const key of ["font", "fontName", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"]) {
|
||||
const value = formats[0][key];
|
||||
text[key] = formats.every(format => format[key] === value) ? value : undefined;
|
||||
}
|
||||
const layouts = item.items.map(member => member.richText ? { ...richTextLayout(member.richText), wrap: Boolean(member.richText.layout.wrap) } : {
|
||||
align: member.align || "left", wrap: Boolean(member.wrap), lineHeight: member.paragraphHeight || 0,
|
||||
letterSpacing: member.letterSpacing || 0, leftIndent: member.leftIndent || 0, rightIndent: member.rightIndent || 0, firstLineIndent: member.firstLineIndent || 0,
|
||||
});
|
||||
for (const key of ["align", "wrap", "lineHeight", "letterSpacing", "leftIndent", "rightIndent", "firstLineIndent"]) {
|
||||
const values = layouts.map(layout => layout[key]);
|
||||
text[key === "lineHeight" ? "paragraphHeight" : key] = values.every(value => value === values[0]) ? values[0] : undefined;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export class ObjectIndex {
|
||||
constructor(items) {
|
||||
const build = items => {
|
||||
if (!items.length) return null;
|
||||
const bounds = selectionBounds(items);
|
||||
if (items.length <= 16) return { ...bounds, items };
|
||||
const axis = bounds.width >= bounds.height ? "x" : "y";
|
||||
items.sort((a, b) => a[axis] - b[axis]);
|
||||
const middle = items.length >> 1;
|
||||
return { ...bounds, left: build(items.slice(0, middle)), right: build(items.slice(middle)) };
|
||||
};
|
||||
this.root = build([...items]);
|
||||
}
|
||||
|
||||
queryBox(box) {
|
||||
const result = [];
|
||||
const intersects = node => node.x <= box.x + box.width && node.x + node.width >= box.x
|
||||
&& node.y <= box.y + box.height && node.y + node.height >= box.y;
|
||||
const visit = node => {
|
||||
if (!node || !intersects(node)) return;
|
||||
if (node.items) {
|
||||
for (const item of node.items) {
|
||||
if (!item.parent && item.x >= box.x && item.x + item.width <= box.x + box.width
|
||||
&& item.y >= box.y && item.y + item.height <= box.y + box.height) result.push(item);
|
||||
}
|
||||
} else { visit(node.left); visit(node.right); }
|
||||
};
|
||||
visit(this.root);
|
||||
return result.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
query(point, tolerance = 0) {
|
||||
const result = [];
|
||||
const contains = box => point.x >= box.x - tolerance && point.x <= box.x + box.width + tolerance
|
||||
&& point.y >= box.y - tolerance && point.y <= box.y + box.height + tolerance;
|
||||
const visit = node => {
|
||||
if (!node || !contains(node)) return;
|
||||
if (node.items) result.push(...node.items.filter(contains));
|
||||
else { visit(node.left); visit(node.right); }
|
||||
};
|
||||
visit(this.root);
|
||||
return result.sort((a, b) => b.order - a.order);
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasEditor {
|
||||
constructor(viewer, options) {
|
||||
this.viewer = viewer;
|
||||
@@ -602,8 +889,7 @@ export class CanvasEditor {
|
||||
const pending = this.pendingSelection?.index === index;
|
||||
const selected = new Set(pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id)
|
||||
: this.items().filter(item => item.index === index).map(item => item.id));
|
||||
const selection = [];
|
||||
this.pages.set(surface, { index, width: page.width, height: page.height });
|
||||
const selection = [], items = [];
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "edit-layer";
|
||||
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }]));
|
||||
@@ -645,6 +931,7 @@ export class CanvasEditor {
|
||||
item.contour = { svg, path: contour };
|
||||
}
|
||||
this.nodes.set(node, item);
|
||||
items.push(item);
|
||||
node.addEventListener("focus", () => {
|
||||
if (this.input) this.selectAfterText(item);
|
||||
else this.select(item);
|
||||
@@ -655,6 +942,7 @@ export class CanvasEditor {
|
||||
selection.push(item);
|
||||
}
|
||||
}
|
||||
this.pages.set(surface, { index, width: page.width, height: page.height, hitIndex: new ObjectIndex(items) });
|
||||
surface.append(layer);
|
||||
if (pending || selected.size) this.setSelection(selection);
|
||||
if (pending) this.pendingSelection = null;
|
||||
@@ -695,16 +983,17 @@ export class CanvasEditor {
|
||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
||||
});
|
||||
if (item.contour) item.contour.svg.setAttribute("viewBox", `${item.x} ${item.y} ${item.width} ${item.height}`);
|
||||
if (item.textFrame) {
|
||||
const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame;
|
||||
const box = item.textFrame.content || { x: 0, y: 0, width, height };
|
||||
for (const handle of item.node.children) {
|
||||
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
||||
const x = box.x + (handle.dataset.corner === "e" ? box.width : 0), y = box.y + box.height / 2;
|
||||
handle.style.left = `${(a * x + c * y + e - item.x) * change.scale * PX_PER_MM}px`;
|
||||
handle.style.top = `${(b * x + d * y + f - item.y) * change.scale * PX_PER_MM}px`;
|
||||
handle.style.cursor = a === 0 ? "var(--resize-vertical, ns-resize)" : "var(--resize-horizontal, ew-resize)";
|
||||
}
|
||||
if (item.textFrame) this.placeTextHandles(item, textFrameBox(item), item, change.scale);
|
||||
}
|
||||
|
||||
placeTextHandles(item, box, bounds, scale = 1) {
|
||||
const [a, b, c, d, e, f] = item.textFrame.matrix;
|
||||
for (const handle of item.node.children) {
|
||||
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
||||
const x = box.x + (handle.dataset.corner === "e" ? box.width : 0), y = box.y + box.height / 2;
|
||||
handle.style.left = `${(a * x + c * y + e - bounds.x) * scale * PX_PER_MM}px`;
|
||||
handle.style.top = `${(b * x + d * y + f - bounds.y) * scale * PX_PER_MM}px`;
|
||||
handle.style.cursor = a === 0 ? "var(--resize-vertical, ns-resize)" : "var(--resize-horizontal, ew-resize)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -793,7 +1082,7 @@ export class CanvasEditor {
|
||||
const handles = !canEditObject(item, "transform") ? [] : lineShape(item.shape) ? ["start", "end"]
|
||||
: item.shape || item.oriented || canEditObject(item, "stretch") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
|
||||
: item.type === "TextObject" && canEditObject(item, "reflow") && canEditObject(item, "layoutKnown")
|
||||
? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"];
|
||||
? ["nw", "ne", "sw", "se", "w", "e"] : ["nw", "ne", "sw", "se"];
|
||||
for (const corner of handles) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = `edit-handle edit-${corner}`;
|
||||
@@ -917,9 +1206,10 @@ export class CanvasEditor {
|
||||
};
|
||||
if (!this.drag.corner) {
|
||||
const selected = this.items();
|
||||
this.drag.targets = [...item.surface.querySelectorAll(".edit-object")]
|
||||
const targets = [...item.surface.querySelectorAll(".edit-object")]
|
||||
.map(node => this.nodes.get(node)).filter(member => !selected.some(item => item === member || item.id.startsWith(member.id + "/") || member.id.startsWith(item.id + "/")));
|
||||
this.drag.targets.push({ x: 0, y: 0, ...item.page });
|
||||
targets.push({ x: 0, y: 0, ...item.page });
|
||||
this.drag.alignment = new AlignmentIndex(targets);
|
||||
}
|
||||
if (lineShape(item.shape) && this.drag.corner) {
|
||||
Object.assign(this.drag, this.createPreview(item.surface, item.page, item.shape, {
|
||||
@@ -956,7 +1246,7 @@ export class CanvasEditor {
|
||||
const rect = surface.getBoundingClientRect(), rotation = this.options.rotation();
|
||||
const point = pagePoint(event.clientX, event.clientY, rect, page, rotation);
|
||||
const tolerance = 3 * page.width / (rotation % 180 ? rect.height : rect.width);
|
||||
return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => !item.parent || this.items().some(selected => selected.id.startsWith(item.parent + "/"))).filter(item => item.type === "TextObject"
|
||||
return page.hitIndex.query(point, tolerance).filter(item => !item.parent || this.items().some(selected => selected.id.startsWith(item.parent + "/"))).filter(item => item.type === "TextObject"
|
||||
? point.x >= item.x - tolerance && point.x <= item.x + item.width + tolerance
|
||||
&& point.y >= item.y - tolerance && point.y <= item.y + item.height + tolerance
|
||||
: this.contains(item, point, tolerance));
|
||||
@@ -1012,6 +1302,7 @@ export class CanvasEditor {
|
||||
}
|
||||
if (drag.marquee) {
|
||||
this.moveMarquee(event, drag);
|
||||
this.autoScroll(drag);
|
||||
return;
|
||||
}
|
||||
if (!drag.change && !drag.box && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
|
||||
@@ -1023,11 +1314,12 @@ export class CanvasEditor {
|
||||
if (drag.item.textFrame) {
|
||||
const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix;
|
||||
const dx = (d * (to.x - from.x) - c * (to.y - from.y)) / (a * d - b * c);
|
||||
const box = reshapeBox({ geometry: frame.content || { x: 0, y: 0, width: frame.width, height: frame.height } }, dx, 0, drag.corner, false);
|
||||
const box = reshapeBox({ geometry: textFrameBox(drag.item) }, dx, 0, drag.corner, false);
|
||||
drag.box = { x: drag.item.x + box.x, width: box.width };
|
||||
const bounds = transformedBox(box, frame.matrix);
|
||||
Object.assign(drag.item.node.style, { left: `${bounds.x * PX_PER_MM}px`, top: `${bounds.y * PX_PER_MM}px`,
|
||||
width: `${bounds.width * PX_PER_MM}px`, height: `${bounds.height * PX_PER_MM}px` });
|
||||
this.placeTextHandles(drag.item, box, bounds);
|
||||
return;
|
||||
}
|
||||
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
||||
@@ -1070,12 +1362,12 @@ export class CanvasEditor {
|
||||
|
||||
scroll() {
|
||||
const drag = this.drag;
|
||||
if (!drag?.item || drag.corner || !drag.pointer) {
|
||||
if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
drag.rect = drag.item.surface.getBoundingClientRect();
|
||||
drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item?.page || drag.page, drag.rotation);
|
||||
drag.rect = (drag.item?.surface || drag.surface).getBoundingClientRect();
|
||||
this.move(drag.pointer);
|
||||
}
|
||||
|
||||
@@ -1091,8 +1383,10 @@ export class CanvasEditor {
|
||||
const elapsed = Math.min(32, time - (drag.scrollTime ?? time - 16));
|
||||
drag.scrollTime = time;
|
||||
const left = viewer.scrollLeft, top = viewer.scrollTop;
|
||||
viewer.scrollLeft += speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed;
|
||||
viewer.scrollTop += speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed;
|
||||
const x = speed(pointer.clientX, rect.left, viewer.clientWidth) * elapsed;
|
||||
const y = speed(pointer.clientY, rect.top, viewer.clientHeight) * elapsed;
|
||||
if (this.options.scrollBy) this.options.scrollBy(x, y);
|
||||
else { viewer.scrollLeft += x; viewer.scrollTop += y; }
|
||||
if (viewer.scrollLeft !== left || viewer.scrollTop !== top) this.scroll();
|
||||
else drag.scrollTime = null;
|
||||
});
|
||||
@@ -1100,7 +1394,7 @@ export class CanvasEditor {
|
||||
|
||||
snap(drag, bypass = false) {
|
||||
const { item, rect, rotation, change } = drag;
|
||||
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
|
||||
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : drag.alignment.snap({ ...item, x: item.x + change.x, y: item.y + change.y }, {
|
||||
x: drag.axis === "y" ? 0 : 4 * item.page.width / (rotation % 180 ? rect.height : rect.width),
|
||||
y: drag.axis === "x" ? 0 : 4 * item.page.height / (rotation % 180 ? rect.width : rect.height),
|
||||
});
|
||||
@@ -1185,7 +1479,7 @@ export class CanvasEditor {
|
||||
this.drag = null;
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
const frame = drag.item.textFrame;
|
||||
const geometry = drag.item.oriented?.box || drag.item.geometry || (frame ? { x: drag.item.x + (frame.content?.x || 0), width: frame.content?.width ?? frame.width } : drag.item);
|
||||
const geometry = drag.item.oriented?.box || drag.item.geometry || (frame ? { x: drag.item.x, width: textFrameBox(drag.item).width } : drag.item);
|
||||
const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]);
|
||||
const apply = drag.item.shape || drag.item.oriented ? this.options.onReshape : canEditObject(drag.item, "stretch") ? this.options.onResize : this.options.onTextWidth;
|
||||
Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => {
|
||||
@@ -1323,17 +1617,15 @@ export class CanvasEditor {
|
||||
}
|
||||
|
||||
moveMarquee(event, drag) {
|
||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||
const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation);
|
||||
const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) };
|
||||
paintShape(drag.node, "rectangle", box);
|
||||
if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return;
|
||||
const items = [...drag.base];
|
||||
for (const node of drag.surface.querySelectorAll(".edit-object")) {
|
||||
const item = this.nodes.get(node);
|
||||
if (!item.parent && !items.includes(item) && item.x >= box.x && item.x+item.width <= box.x+box.width && item.y >= box.y && item.y+item.height <= box.y+box.height) {
|
||||
items.push(item);
|
||||
}
|
||||
const selected = new Set(items);
|
||||
for (const item of drag.page.hitIndex.queryBox(box)) {
|
||||
if (!selected.has(item)) items.push(item);
|
||||
}
|
||||
this.setSelection(items);
|
||||
}
|
||||
@@ -1537,9 +1829,9 @@ export class CanvasEditor {
|
||||
else paragraph.append(document.createElement("br"));
|
||||
input.append(paragraph);
|
||||
}
|
||||
const left = item.leftIndent || 0;
|
||||
const frame = { ...(item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] }) };
|
||||
if (item.wrap && item.paragraphWidth != null) frame.width = item.paragraphWidth;
|
||||
const left = rich ? 0 : item.leftIndent || 0;
|
||||
const frame = { ...(rich?.frame || item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] }) };
|
||||
if (!rich?.frame && item.paragraphWidth != null) frame.width = item.paragraphWidth;
|
||||
const [a, b, c, d, e, f] = frame.matrix;
|
||||
Object.assign(input.style, {
|
||||
left: "0px", top: "0px",
|
||||
@@ -1554,12 +1846,11 @@ export class CanvasEditor {
|
||||
});
|
||||
this.input = { input, item, face, faces, frame, source: Boolean(source), fontChoice: item.fontChoice };
|
||||
const editing = this.input;
|
||||
if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts);
|
||||
if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources);
|
||||
if (source) {
|
||||
editing.selectionListener = () => this.paintSourceSelection();
|
||||
document.addEventListener("selectionchange", editing.selectionListener);
|
||||
}
|
||||
if (rich && !rich.model.layout.lineHeight) input.style.lineHeight = "normal";
|
||||
if (source) editing.history = { entries: [{ value: item.text, face, fontChoice: item.fontChoice }], index: 0 };
|
||||
item.artwork?.classList.add("edit-text-source");
|
||||
item.node.classList.add("edit-text-source");
|
||||
@@ -1594,8 +1885,21 @@ export class CanvasEditor {
|
||||
});
|
||||
});
|
||||
input.addEventListener("paste", event => {
|
||||
if (editing.rich) { editing.rich.paste(event); return; }
|
||||
if (editing.rich) {
|
||||
editing.rich.paste(event).catch(error => { if (this.input === editing) this.setTextError(error); });
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const copy = this.options.getTextClipboard ? this.options.getTextClipboard() : this.richClipboard;
|
||||
if (copy && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && event.clipboardData.getData("text/plain") === copy.text && this.options.onRichInput) {
|
||||
const data = new Map([RICH_CLIPBOARD, "text/plain"].map(key => [key, event.clipboardData.getData(key)]));
|
||||
const value = editTextValue(input), caret = editTextSelection(input, editing.range);
|
||||
Promise.resolve(this.options.onRichInput()).then(ready => {
|
||||
if (ready && this.input === editing && editing.rich.text === value && (!caret || editing.rich.selection().every((at, i) => at === caret[i])))
|
||||
return editing.rich.paste({ preventDefault() {}, clipboardData: { getData: key => data.get(key) || "" } });
|
||||
}).catch(error => { if (this.input === editing) this.setTextError(error); });
|
||||
return;
|
||||
}
|
||||
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
||||
});
|
||||
input.addEventListener("beforeinput", event => {
|
||||
@@ -1603,7 +1907,21 @@ export class CanvasEditor {
|
||||
if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
|
||||
event.preventDefault(); editing.rich.undo(event.inputType === "historyUndo" ? -1 : 1); return;
|
||||
}
|
||||
if (!editing.composing) { editing.rich.remember(); editing.rich.beforeCaret = editing.rich.selection(); }
|
||||
if (!editing.composing && ["deleteContentBackward", "deleteContentForward"].includes(event.inputType) && input.querySelector("br[data-text-wrap]")) {
|
||||
event.preventDefault();
|
||||
const rich = editing.rich, range = rich.selection();
|
||||
if (range[0] === range[1]) {
|
||||
const bounds = [0, ...Array.from(graphemes.segment(rich.text), part => part.index + part.segment.length)];
|
||||
if (event.inputType === "deleteContentBackward") range[0] = bounds.findLast(at => at < range[0]) ?? 0;
|
||||
else range[1] = bounds.find(at => at > range[1]) ?? rich.text.length;
|
||||
}
|
||||
rich.replace([], null, range); return;
|
||||
}
|
||||
if (!editing.composing) {
|
||||
editing.rich.remember();
|
||||
const range = event.getTargetRanges()[0];
|
||||
editing.rich.beforeCaret = range ? editTextSelection(input, range) : editing.rich.selection();
|
||||
}
|
||||
}
|
||||
if (editing.source && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) {
|
||||
event.preventDefault();
|
||||
@@ -1639,10 +1957,11 @@ export class CanvasEditor {
|
||||
if (event.isComposing || editing.composing || editing.saving) {
|
||||
return;
|
||||
}
|
||||
if (editing.rich && (event.ctrlKey || event.metaKey) && !event.altKey && ["b", "i", "u"].includes(event.key.toLowerCase())) {
|
||||
if ((editing.rich || this.options.onTextFormat) && (event.ctrlKey || event.metaKey) && !event.altKey && ["b", "i", "u"].includes(event.key.toLowerCase())) {
|
||||
event.preventDefault();
|
||||
const key = { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()];
|
||||
editing.rich.apply({ [key]: editing.rich.state()[key] !== true });
|
||||
if (this.options.onTextFormat) this.options.onTextFormat(key);
|
||||
else editing.rich.apply({ [key]: editing.rich.state()[key] !== true });
|
||||
} else if ((editing.source || editing.rich) && (event.ctrlKey || event.metaKey) && !event.altKey && ["z", "y"].includes(event.key.toLowerCase())) {
|
||||
event.preventDefault();
|
||||
this.textUndo(event.key.toLowerCase() === "y" || event.shiftKey ? 1 : -1);
|
||||
@@ -1655,8 +1974,8 @@ export class CanvasEditor {
|
||||
this.commitText();
|
||||
}
|
||||
});
|
||||
input.addEventListener("copy", event => editing.rich?.copy(event));
|
||||
input.addEventListener("cut", event => editing.rich?.copy(event, true));
|
||||
input.addEventListener("copy", event => this.copyText(event));
|
||||
input.addEventListener("cut", event => this.copyText(event, true));
|
||||
if (editing.rich) editing.rich.render();
|
||||
input.focus({ preventScroll: true });
|
||||
if (!item.draft) document.getSelection().selectAllChildren(input);
|
||||
@@ -1669,20 +1988,34 @@ export class CanvasEditor {
|
||||
if (this.input?.rich) this.input.rich.undo(direction);
|
||||
else this.restoreSourceText(direction);
|
||||
}
|
||||
copyText(event, cut = false) {
|
||||
const editing = this.input;
|
||||
if (!editing || editing.composing || editing.saving) return;
|
||||
if (editing.rich) return editing.rich.copy(event, cut);
|
||||
if (editing.item.selectionIDs) return;
|
||||
const range = editTextSelection(editing.input, editing.range);
|
||||
if (!range || range[0] === range[1]) return;
|
||||
const [start, end] = range.sort((a, b) => a - b), text = editTextValue(editing.input).slice(start, end);
|
||||
const model = richTextModel({ ...editing.item, ...editing.style, text });
|
||||
const font = editing.fontData || editing.fontResource;
|
||||
const clipboard = { token: crypto.randomUUID(), text, runs: model.runs, scope: this.options.textClipboardScope?.(),
|
||||
fonts: new Map(font ? [[editing.item.font, font]] : []), faces: new Map([[editing.item.font, editing.face]]), choices: new Map(),
|
||||
paragraphs: text.split("\n").map(() => ({ align: "left" })) };
|
||||
if (this.options.setTextClipboard) this.options.setTextClipboard(clipboard);
|
||||
else this.richClipboard = clipboard;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", text);
|
||||
event.clipboardData.setData(RICH_CLIPBOARD, clipboard.token);
|
||||
if (cut) document.execCommand("delete");
|
||||
}
|
||||
|
||||
fitTextInput() {
|
||||
const editing = this.input;
|
||||
if (!editing || editing.source) return;
|
||||
const { input, frame, item, rich } = editing;
|
||||
const layout = rich?.model.layout || item;
|
||||
const width = Math.max(0, frame.width - (layout.leftIndent || 0) - (layout.rightIndent || 0)) * PX_PER_MM + 4;
|
||||
const autoWidth = !layout.align || layout.align === "left";
|
||||
Object.assign(input.style, {
|
||||
width: autoWidth ? "max-content" : `${width}px`,
|
||||
maxWidth: layout.wrap ? `${width}px` : "none",
|
||||
minWidth: `${Math.min(width, (rich?.typing.size || editing.style?.size || item.size) * PX_PER_MM + 4)}px`,
|
||||
minHeight: "0px", height: "auto",
|
||||
});
|
||||
const width = Math.max(0, frame.width - (rich ? 0 : layout.leftIndent || 0) - (rich ? 0 : layout.rightIndent || 0)) * PX_PER_MM + 4;
|
||||
Object.assign(input.style, { width: rich?.model.autoWidth && !layout.wrap ? "max-content" : `${width}px`, minWidth: "4px", minHeight: "0px", height: "auto" });
|
||||
}
|
||||
|
||||
setTextError(error) {
|
||||
@@ -1705,35 +2038,39 @@ export class CanvasEditor {
|
||||
if (!editing || editing.rich || editing.composing || editing.saving) return false;
|
||||
const caret = editTextSelection(editing.input, editing.range) || [0, 0];
|
||||
const value = editTextValue(editing.input);
|
||||
const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts);
|
||||
const original = session.snapshot();
|
||||
const format = { ...session.typing, ...editing.style };
|
||||
if (editing.fontData) {
|
||||
const key = `temp-font-${crypto.randomUUID()}`;
|
||||
format.font = key;
|
||||
session.imports.set(key, editing.fontData);
|
||||
session.faces.set(key, editing.face);
|
||||
if (editing.fontChoice) session.choices.set(key, editing.fontChoice);
|
||||
const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources);
|
||||
const base = { ...session.typing }, fonts = new Map();
|
||||
const current = { value, style: editing.style, face: editing.face, fontData: editing.fontData, fontChoice: editing.fontChoice, caret };
|
||||
const values = editing.history ? editing.history.entries.slice(0, editing.history.index + 1) : [{ value: editing.item.text }];
|
||||
for (const entry of [...values, current]) {
|
||||
const format = { ...base, ...entry.style };
|
||||
if (entry.fontData) {
|
||||
let key = fonts.get(entry.fontData);
|
||||
if (!key) {
|
||||
key = `temp-font-${crypto.randomUUID()}`; fonts.set(entry.fontData, key);
|
||||
session.imports.set(key, entry.fontData); session.faces.set(key, entry.face);
|
||||
document.fonts.add(entry.face);
|
||||
if (entry.fontChoice) session.choices.set(key, entry.fontChoice);
|
||||
}
|
||||
format.font = key;
|
||||
}
|
||||
const before = session.model.runs;
|
||||
session.model.runs = normalizeRuns([{ text: entry.value, format }]);
|
||||
session.typing = format;
|
||||
session.caret = entry.caret || caret;
|
||||
session.record(before);
|
||||
}
|
||||
const history = editing.history;
|
||||
const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }];
|
||||
const entries = [original];
|
||||
for (const entry of values) {
|
||||
const snapshot = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: entry.value, format }]) },
|
||||
typing: { ...format }, caret: entry.caret || caret };
|
||||
if (JSON.stringify(snapshot.model) !== JSON.stringify(entries.at(-1).model)) entries.push(snapshot);
|
||||
if (rich.frame) {
|
||||
editing.frame = rich.frame;
|
||||
editing.item = { ...editing.item, richReflow: rich.reflow, textFrame: rich.frame };
|
||||
}
|
||||
const current = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: value, format }]) }, typing: { ...format }, caret };
|
||||
if (JSON.stringify(current.model) !== JSON.stringify(entries.at(-1).model)) entries.push(current);
|
||||
Object.assign(session, { entries, index: entries.length - 1, model: current.model, typing: format, caret });
|
||||
editing.rich = session;
|
||||
editing.source = false;
|
||||
document.removeEventListener("selectionchange", editing.selectionListener);
|
||||
editing.history = null;
|
||||
editing.range = null;
|
||||
const [a, b, c, d, e, f] = editing.frame.matrix, left = editing.item.leftIndent || 0;
|
||||
const [a, b, c, d, e, f] = editing.frame.matrix, left = 0;
|
||||
editing.input.style.transform = `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`;
|
||||
if (!rich.model.layout.lineHeight) editing.input.style.lineHeight = "normal";
|
||||
session.render();
|
||||
session.preview();
|
||||
this.options.onTextChange();
|
||||
@@ -1974,12 +2311,19 @@ export class CanvasEditor {
|
||||
async commitText() {
|
||||
const editing = this.input;
|
||||
if (!editing) return true;
|
||||
if (editing.saving) return false;
|
||||
if (editing.saving || editing.preparingCommit) return false;
|
||||
if (editing.composing) {
|
||||
editing.commitAfterComposition = true;
|
||||
return false;
|
||||
}
|
||||
editing.commitAfterComposition = false;
|
||||
const preparation = this.options.prepareTextCommit?.();
|
||||
if (preparation) {
|
||||
editing.preparingCommit = true;
|
||||
try { await preparation; }
|
||||
finally { editing.preparingCommit = false; }
|
||||
if (this.input !== editing) return !this.input;
|
||||
}
|
||||
const value = editTextValue(editing.input);
|
||||
if (!this.textChanged()) {
|
||||
this.closeText();
|
||||
|
||||
+99
-22
@@ -1,17 +1,18 @@
|
||||
const DATABASE = "ofdgo";
|
||||
|
||||
export class FontPicker {
|
||||
constructor(input, toggle, list, onChange, onOpen) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
||||
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
|
||||
input.addEventListener("input", () => this.show(input.value));
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
||||
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
|
||||
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
|
||||
input.addEventListener("keydown", event => this.keyDown(event));
|
||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.open) this.close();
|
||||
else { input.focus(); this.show(); }
|
||||
else { input.focus(); input.select(); this.show(); }
|
||||
});
|
||||
list.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest(".font-option")) event.preventDefault();
|
||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
||||
window.addEventListener("resize", () => this.close());
|
||||
}
|
||||
|
||||
setFonts(fonts, selected) {
|
||||
this.close();
|
||||
setFonts(fonts, selected, preserve = false) {
|
||||
const open = preserve && this.open;
|
||||
if (!open) this.close();
|
||||
this.fonts = fonts;
|
||||
this.groups = fonts.map((_, index) => [index]);
|
||||
this.names = new Map();
|
||||
for (const font of fonts) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
const key = font => font?.id || font?.postscriptName;
|
||||
const key = font => font?.richKey || font?.id || font?.postscriptName;
|
||||
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
||||
this.restore();
|
||||
if (open) {
|
||||
const active = this.matches[this.active]?.font;
|
||||
this.show(this.query, false);
|
||||
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
|
||||
if (index >= 0) this.activate(index);
|
||||
} else this.restore();
|
||||
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
|
||||
if (this.fonts !== fonts) return;
|
||||
this.groups = groups;
|
||||
this.names.clear();
|
||||
for (const group of groups) {
|
||||
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
if (this.open) {
|
||||
const active = this.matches[this.active]?.index;
|
||||
this.show(this.query, false);
|
||||
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
|
||||
if (index >= 0) this.activate(index);
|
||||
}
|
||||
else this.restore();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
||||
|
||||
restore() {
|
||||
const font = this.fonts[Number(this.value)];
|
||||
this.input.value = font?.fullName || font?.name || "";
|
||||
const value = font?.menuFamily || font?.fullName || font?.name || "";
|
||||
if (this.input.value !== value) {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.select();
|
||||
}
|
||||
this.input.scrollLeft = 0;
|
||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||
}
|
||||
|
||||
sourceLabel(font) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
if (this.names.get(name) < 2) return "";
|
||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||
}
|
||||
|
||||
show(query = "", load = true) {
|
||||
if (this.input.disabled) return;
|
||||
this.query = query;
|
||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
||||
this.matches = this.groups.map(group => {
|
||||
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
|
||||
return { font: this.fonts[index], index, group };
|
||||
}).filter(({ group }) => {
|
||||
const name = normalize(group.flatMap(index => {
|
||||
const font = this.fonts[index];
|
||||
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
|
||||
}).filter(Boolean).join(" "));
|
||||
return terms.every(term => name.includes(term));
|
||||
});
|
||||
this.list.replaceChildren();
|
||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
||||
option.dataset.index = String(index);
|
||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||
const name = document.createElement("span");
|
||||
name.textContent = font.fullName || font.name;
|
||||
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||
option.append(name);
|
||||
const source = this.sourceLabel(font);
|
||||
if (source) {
|
||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
||||
if (!this.open) this.list.showPopover();
|
||||
this.open = true;
|
||||
this.position();
|
||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
||||
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
|
||||
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||
if (load) this.onOpen?.();
|
||||
}
|
||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
||||
const changed = this.value !== String(index);
|
||||
this.value = String(index);
|
||||
this.close();
|
||||
if (changed) return this.onChange();
|
||||
if (changed) {
|
||||
const task = this.changing = Promise.resolve(this.onChange());
|
||||
return task.finally(() => { if (this.changing === task) this.changing = null; });
|
||||
}
|
||||
}
|
||||
|
||||
commitExact() {
|
||||
const value = this.input.value.trim().toLocaleLowerCase();
|
||||
const matches = font => font && !font.disabled
|
||||
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
const selected = Number(this.value);
|
||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
||||
if (index >= 0) this.choose(index);
|
||||
if (index >= 0) return this.choose(index);
|
||||
}
|
||||
|
||||
prepareCommit(event) {
|
||||
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
|
||||
this.commitExact();
|
||||
this.close();
|
||||
}
|
||||
return this.changing;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -314,13 +360,44 @@ export class FontManager {
|
||||
}), ...this.catalog];
|
||||
}
|
||||
|
||||
async loadFaces(inspect) {
|
||||
while (true) {
|
||||
async loadFaces(inspect, current = () => true) {
|
||||
while (current()) {
|
||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
||||
if (!file) return;
|
||||
if (!file) break;
|
||||
try { file.faces = await inspect(await this.read(file)); }
|
||||
catch { file.faces = []; }
|
||||
catch { if (!current()) return; file.faces = []; }
|
||||
}
|
||||
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||
while (current() && pending.length) {
|
||||
const font = pending.shift();
|
||||
try {
|
||||
const faces = await inspect(await this.read(font));
|
||||
const face = faces.find(face => face.postscriptName === font.postscriptName);
|
||||
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
|
||||
else font.metadataFailed = true;
|
||||
} catch { if (current()) font.metadataFailed = true; }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async defaultFont(order, inspect, current = () => true) {
|
||||
const fonts = this.editorFonts().filter(font => !font.disabled);
|
||||
if (!fonts.length) return;
|
||||
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
|
||||
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
|
||||
const indices = await order(fonts, families);
|
||||
let fallback = fonts.length;
|
||||
for (const index of indices) {
|
||||
if (!current()) return;
|
||||
try {
|
||||
await inspect(fonts[index], "中文汉字编辑水印,。!?");
|
||||
if (current()) return fonts[index];
|
||||
} catch (error) {
|
||||
if (error.missingGlyphs) fallback = Math.min(fallback, index);
|
||||
}
|
||||
}
|
||||
if (current()) return fonts[fallback];
|
||||
}
|
||||
|
||||
async add(fonts) {
|
||||
|
||||
@@ -16,6 +16,7 @@ package font
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -23,6 +24,8 @@ import (
|
||||
)
|
||||
|
||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||
type Face struct {
|
||||
Index int `json:"index"`
|
||||
FullName string `json:"fullName"`
|
||||
@@ -30,6 +33,11 @@ type Face struct {
|
||||
Style string `json:"style"`
|
||||
PostScriptName string `json:"postscriptName"`
|
||||
Names []string `json:"names"`
|
||||
MenuFamily string `json:"menuFamily"`
|
||||
MenuStyle string `json:"menuStyle"`
|
||||
FamilyKey string `json:"familyKey"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
|
||||
// FaceInfo 从名称表读取字体信息
|
||||
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
||||
}
|
||||
return name(ids...)
|
||||
}
|
||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)}
|
||||
key := ""
|
||||
if family, version := name(1), name(5); family != "" && version != "" {
|
||||
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
|
||||
}
|
||||
family, style := localized(1), localized(2)
|
||||
if family == "" {
|
||||
family = localized(16, 4, 6)
|
||||
}
|
||||
if style == "" {
|
||||
style = localized(17)
|
||||
}
|
||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
|
||||
}
|
||||
|
||||
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
|
||||
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
|
||||
// 返回: Face 字体信息
|
||||
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
|
||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||
key := face.FamilyKey
|
||||
if head := tables["head"]; len(head) >= 46 {
|
||||
key += string(head[4:8])
|
||||
style := binary.BigEndian.Uint16(head[44:])
|
||||
face.Bold, face.Italic = style&1 != 0, style&2 != 0
|
||||
}
|
||||
if os2 := tables["OS/2"]; len(os2) >= 64 {
|
||||
key += string(os2[58:62])
|
||||
style := binary.BigEndian.Uint16(os2[62:])
|
||||
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
|
||||
}
|
||||
if face.FamilyKey != "" {
|
||||
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
|
||||
}
|
||||
return face
|
||||
}
|
||||
|
||||
// FileCount 校验字体或集合头并读取字体数量
|
||||
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||
face := TableFaceInfo(tables, index, platform, language)
|
||||
if face.FullName == "" {
|
||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
||||
values := make(map[[4]uint16]string)
|
||||
for _, record := range records {
|
||||
switch record.Name {
|
||||
case 1, 2, 4, 6, 16, 17:
|
||||
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
+411
-149
@@ -38,8 +38,8 @@ import (
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
)
|
||||
|
||||
// sessionTextCacheLimit 限制会话保留的页面文字索引数量
|
||||
const sessionTextCacheLimit = 32
|
||||
// sessionTextCacheBytes 限制会话文字索引的内存预算
|
||||
const sessionTextCacheBytes = 32 << 20
|
||||
|
||||
// OpenOptions 打开OFD文档选项
|
||||
type OpenOptions struct {
|
||||
@@ -63,8 +63,7 @@ type Session struct {
|
||||
doc *ofdgo.Document
|
||||
pageCache map[int]*ofdgo.PageContent
|
||||
boxCache []pageBoxInfo
|
||||
textCache map[int]*ofdgo.PageText
|
||||
textOrder []int
|
||||
textCache *ofdgo.PageTextCache
|
||||
svgFonts map[string][]byte
|
||||
fontScan *ofdgo.FontInfoScanner
|
||||
fontInfos []FontInfo
|
||||
@@ -355,10 +354,11 @@ type apiResult struct {
|
||||
ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"`
|
||||
}
|
||||
|
||||
// editorClipboard 当前编辑文档中的对象快照与剪贴板标识
|
||||
// editorClipboard 保存选区标识及同文档快照或跨文档副本
|
||||
type editorClipboard struct {
|
||||
token string
|
||||
selection *ofdgo.ObjectSelection
|
||||
packet *ofdgo.Editor
|
||||
}
|
||||
|
||||
// editorInfo 编辑文档信息与操作状态
|
||||
@@ -451,23 +451,27 @@ type pendingPDF struct {
|
||||
font platformValue
|
||||
operation *outputContext
|
||||
report func(string, int, int) error
|
||||
stage string
|
||||
completed int
|
||||
total int
|
||||
}
|
||||
|
||||
// businessEngine 独立文档、编辑、剪贴板及操作上下文
|
||||
type businessEngine struct {
|
||||
resources *ofdgo.Resources
|
||||
currentSession *Session
|
||||
currentEditor *ofdgo.Editor
|
||||
pdf *pendingPDF
|
||||
pdfWarnings []byte
|
||||
saveSnapshot *ofdgo.Editor
|
||||
printDocument *ofdgo.PrintDocument
|
||||
pendingImport *ofdgo.Reader
|
||||
copiedObjects *editorClipboard
|
||||
copiedStyle *ofdgo.GraphicObject
|
||||
ctx context.Context
|
||||
resolveInput func(context.Context, any) (io.ReaderAt, int64, error)
|
||||
conversionPool *ofdgo.ConversionPool
|
||||
resources *ofdgo.Resources
|
||||
currentSession *Session
|
||||
currentEditor *ofdgo.Editor
|
||||
pdf *pendingPDF
|
||||
pdfWarnings []byte
|
||||
saveSnapshot *ofdgo.Editor
|
||||
printDocument *ofdgo.PrintDocument
|
||||
pendingImport *ofdgo.Reader
|
||||
copiedObjects *editorClipboard
|
||||
portableObjects *editorClipboard
|
||||
copiedStyle *ofdgo.GraphicObject
|
||||
ctx context.Context
|
||||
resolveInput func(context.Context, any) (io.ReaderAt, int64, error)
|
||||
conversionPool *ofdgo.ConversionPool
|
||||
}
|
||||
|
||||
// Open 打开浏览器内存中的OFD文档
|
||||
@@ -596,8 +600,7 @@ func (s *Session) SetFonts(fonts []FontFile) error {
|
||||
s.Renderer.SetFontFS()
|
||||
s.fontFS = nil
|
||||
}
|
||||
clear(s.textCache)
|
||||
s.textOrder = nil
|
||||
s.textCache.Clear()
|
||||
clear(s.svgFonts)
|
||||
s.resetFontInfo()
|
||||
return nil
|
||||
@@ -623,24 +626,13 @@ func (s *Session) PageText(index int) (*ofdgo.PageText, error) {
|
||||
// 入参: index 页面索引
|
||||
// 返回: *ofdgo.PageText 文字索引,未缓存时为nil
|
||||
func (s *Session) cachedPageText(index int) *ofdgo.PageText {
|
||||
text := s.textCache[index]
|
||||
if text != nil {
|
||||
s.cachePageText(index, text)
|
||||
}
|
||||
return text
|
||||
return s.textCache.Get(index)
|
||||
}
|
||||
|
||||
// cachePageText 保留最近访问的文字索引,避免全文搜索持续占用内存
|
||||
// cachePageText 按内存预算保留页面文字索引,复用阅读与搜索结果
|
||||
// 入参: index 页面索引, text 文字索引
|
||||
func (s *Session) cachePageText(index int, text *ofdgo.PageText) {
|
||||
if position := slices.Index(s.textOrder, index); position >= 0 {
|
||||
s.textOrder = slices.Delete(s.textOrder, position, position+1)
|
||||
} else if len(s.textOrder) == sessionTextCacheLimit {
|
||||
delete(s.textCache, s.textOrder[0])
|
||||
s.textOrder = slices.Delete(s.textOrder, 0, 1)
|
||||
}
|
||||
s.textOrder = append(s.textOrder, index)
|
||||
s.textCache[index] = text
|
||||
s.textCache.Put(index, text)
|
||||
}
|
||||
|
||||
// PageTextString 获取页面原文,不为全文复制保留额外的页面和文字索引
|
||||
@@ -765,6 +757,13 @@ func (s *Session) Details() DocumentDetails {
|
||||
}
|
||||
info.SignatureCount = len(info.Signatures)
|
||||
if fonts, err := s.Renderer.DeclaredFontInfos(); err == nil {
|
||||
for index := range fonts {
|
||||
if fonts[index].Embedded {
|
||||
if face, err := s.Reader.FontFace(fonts[index].ID, 3, 0x0804); err == nil {
|
||||
fonts[index].MatchedFace = face
|
||||
}
|
||||
}
|
||||
}
|
||||
info.Fonts = fonts
|
||||
info.FontCount = len(fonts)
|
||||
}
|
||||
@@ -879,8 +878,7 @@ func (s *Session) SetRenderBackend(name string) error {
|
||||
}
|
||||
previous, next := s.Renderer.Backends().Info(), backends.Info()
|
||||
if previous.Compiler != next.Compiler || previous.Fonts != next.Fonts || previous.Geometry != next.Geometry {
|
||||
clear(s.textCache)
|
||||
s.textOrder = nil
|
||||
s.textCache.Clear()
|
||||
}
|
||||
ofdgo.WithRenderBackends(backends)(s.Renderer)
|
||||
return nil
|
||||
@@ -984,7 +982,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
|
||||
doc: doc,
|
||||
pageCache: make(map[int]*ofdgo.PageContent),
|
||||
boxCache: make([]pageBoxInfo, len(doc.Pages.Page)),
|
||||
textCache: make(map[int]*ofdgo.PageText),
|
||||
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes),
|
||||
svgFonts: make(map[string][]byte),
|
||||
}, nil
|
||||
}
|
||||
@@ -1494,7 +1492,7 @@ func (e *businessEngine) openPDF(args []platformValue) (any, error) {
|
||||
}
|
||||
password = slices.Clone(password)
|
||||
}
|
||||
pending.operation, pending.report = e.newEditorOperationContext(pending.progress)
|
||||
e.bindPDFOperation(pending)
|
||||
options := ofdgo.PDFImportOptions{Resources: e.resources, PasswordUTF8: true, Password: password,
|
||||
OnProgress: func(stage string, completed, total int) error {
|
||||
return pending.report(stage, completed, total)
|
||||
@@ -1624,13 +1622,27 @@ func (e *businessEngine) checkDocument(name string) error {
|
||||
return nil
|
||||
}
|
||||
switch name {
|
||||
case "ofdgoCheckDocument", "ofdgoPDFContext", "ofdgoOpenPDF", "ofdgoOpen", "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices", "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoOpenLinks", "ofdgoDiscardPrint", "ofdgoDiscardSave", "ofdgoDetectFormat", "ofdgoConvertFile", "ofdgoPackFiles", "ofdgoLoadImport", "ofdgoCreateDocument", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF":
|
||||
case "ofdgoDiscardObjects", "ofdgoExportObjects", "ofdgoCheckDocument", "ofdgoPDFContext", "ofdgoOpenPDF", "ofdgoOpen", "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices", "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoOpenLinks", "ofdgoDiscardPrint", "ofdgoDiscardSave", "ofdgoDetectFormat", "ofdgoConvertFile", "ofdgoPackFiles", "ofdgoLoadImport", "ofdgoCreateDocument", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF":
|
||||
return nil
|
||||
default:
|
||||
return ofdgo.ErrIncompleteDocument
|
||||
}
|
||||
}
|
||||
|
||||
// bindPDFOperation 更新取消上下文并保留转换阶段,时间片检查不重置进度
|
||||
// 入参: pending 当前转换任务
|
||||
func (e *businessEngine) bindPDFOperation(pending *pendingPDF) {
|
||||
operation, report := e.newEditorOperationContext(pending.progress)
|
||||
pending.operation = operation
|
||||
pending.report = func(stage string, completed, total int) error {
|
||||
pending.stage, pending.completed, pending.total = stage, completed, total
|
||||
return report(stage, completed, total)
|
||||
}
|
||||
operation.checkpoint = func() error {
|
||||
return report(pending.stage, pending.completed, pending.total)
|
||||
}
|
||||
}
|
||||
|
||||
// preparePDF 只为页面读取和后台加载绑定转换上下文,不补全其他命令
|
||||
// 入参: name 命令名称, args 命令参数
|
||||
// 返回: error 转换或取消错误
|
||||
@@ -1643,7 +1655,7 @@ func (e *businessEngine) preparePDF(name string, args []platformValue) error {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
e.pdf.operation, e.pdf.report = e.newEditorOperationContext(e.pdf.progress)
|
||||
e.bindPDFOperation(e.pdf)
|
||||
e.pdf.operation.checked = time.Now()
|
||||
e.pdf.operation.interval = 255
|
||||
e.currentSession.Renderer.OutputContext = e.pdf.operation
|
||||
@@ -1922,8 +1934,7 @@ func (e *businessEngine) configureDocument(args []platformValue) (any, error) {
|
||||
}
|
||||
if e.currentSession.Renderer.RenderAnnotations != args[1].Bool() {
|
||||
e.currentSession.Renderer.RenderAnnotations = args[1].Bool()
|
||||
clear(e.currentSession.textCache)
|
||||
e.currentSession.textOrder = nil
|
||||
e.currentSession.textCache.Clear()
|
||||
}
|
||||
return e.currentSession.Summary(), nil
|
||||
}
|
||||
@@ -3168,7 +3179,37 @@ func (e *businessEngine) fontFaces(args []platformValue) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (ofdgo.FontFile{Data: data}).Faces()
|
||||
return (ofdgo.FontFile{Data: data}).LocalizedFaces(3, 0x0804)
|
||||
}
|
||||
|
||||
// fontMenu 返回按来源隔离的字体族索引及所需款式候选
|
||||
// 入参: args 字体信息、来源及可选目标款式的JSON
|
||||
// 返回: any 分组和款式索引, error 参数错误
|
||||
func (e *businessEngine) fontMenu(args []platformValue) (any, error) {
|
||||
var request struct {
|
||||
Faces []ofdgo.FontFace `json:"faces"`
|
||||
Sources []string `json:"sources"`
|
||||
Bold bool `json:"bold"`
|
||||
Italic bool `json:"italic"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"groups": ofdgo.FontFamilyIndexes(request.Faces, request.Sources), "index": ofdgo.FontStyleIndex(request.Faces, request.Bold, request.Italic)}, nil
|
||||
}
|
||||
|
||||
// fontPreferenceOrder 按字体族偏好排列编辑字体候选索引
|
||||
// 入参: args 候选字体和字体族别名组的JSON数据
|
||||
// 返回: any 候选索引列表, error 错误信息
|
||||
func (e *businessEngine) fontPreferenceOrder(args []platformValue) (any, error) {
|
||||
var request struct {
|
||||
Faces []ofdgo.FontFace `json:"faces"`
|
||||
Families [][]string `json:"families"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ofdgo.FontPreferenceOrder(request.Faces, request.Families), nil
|
||||
}
|
||||
|
||||
// fontFace 提取编辑时选定的字体,供输入预览和保存共用
|
||||
@@ -3397,12 +3438,13 @@ func (e *businessEngine) editorObjects(index int, text *ofdgo.PageText) ([]any,
|
||||
inverse, _ := matrix.Invert()
|
||||
local := inverse.TransformBox(box)
|
||||
width := math.Max(object.TextObject.Size, local.X+local.W)
|
||||
if layout.Wrap {
|
||||
if capability.LayoutKnown {
|
||||
item["paragraphWidth"] = frame.W
|
||||
width = frame.W
|
||||
}
|
||||
frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)}
|
||||
item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(matrix)}
|
||||
if layout.Wrap {
|
||||
if capability.LayoutKnown {
|
||||
bounds := matrix.TransformBox(frame)
|
||||
item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H
|
||||
}
|
||||
@@ -3862,6 +3904,7 @@ func (e *businessEngine) editorAppearance(item map[string]any, object ofdgo.Grap
|
||||
var fill, stroke *ofdgo.FillColor
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
item["bold"], item["italic"] = object.TextObject.Weight >= 700, object.TextObject.Italic
|
||||
fill = object.TextObject.FillColor
|
||||
stroke = (*ofdgo.FillColor)(object.TextObject.StrokeColor)
|
||||
alpha = object.TextObject.Alpha
|
||||
@@ -4012,16 +4055,17 @@ func (e *businessEngine) compositeObjects(args []platformValue) (any, error) {
|
||||
inverse, _ := member.Matrix.Invert()
|
||||
local := inverse.TransformBox(box)
|
||||
width := math.Max(object.Size, local.X+local.W)
|
||||
if layout.Wrap {
|
||||
if member.Capabilities.LayoutKnown {
|
||||
frame, err := object.TextFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item["paragraphWidth"] = frame.W
|
||||
width = frame.W
|
||||
}
|
||||
frame := ofdgo.Box{W: width, H: math.Max(object.Size, local.Y+local.H)}
|
||||
item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(member.Matrix)}
|
||||
if layout.Wrap {
|
||||
if member.Capabilities.LayoutKnown {
|
||||
bounds := member.Matrix.TransformBox(frame)
|
||||
item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H
|
||||
}
|
||||
@@ -4106,16 +4150,8 @@ func (e *businessEngine) changeCompositeObjects(args []platformValue) (any, erro
|
||||
if len(indexes) != 1 {
|
||||
return fmt.Errorf("text layout requires one member")
|
||||
}
|
||||
layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()}
|
||||
if !args[4].IsNull() {
|
||||
return e.currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout)
|
||||
}
|
||||
member, err := e.currentEditor.CompositeObject(page, path, indexes[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, _ := member.Object.TextObject.TextLayout()
|
||||
return e.currentEditor.LayoutCompositeText(page, path, indexes[0], value, layout)
|
||||
path.Children = append(slices.Clone(path.Children), indexes[0])
|
||||
return e.layoutEditorText(page, path, args[4:])
|
||||
case "fit", "resetCrop":
|
||||
if len(indexes) != 1 {
|
||||
return fmt.Errorf("image operation requires one member")
|
||||
@@ -4681,7 +4717,7 @@ func (e *businessEngine) readTextSelectionEdit(args []platformValue, analysis of
|
||||
if err := value.Replace(start, end, replacement); err != nil {
|
||||
return edit, err
|
||||
}
|
||||
value.Layout.AutoBidi, value.Layout.Wrap = true, true
|
||||
value.Layout.AutoBidi = true
|
||||
edit.Reflow = &value
|
||||
}
|
||||
return edit, nil
|
||||
@@ -4889,10 +4925,19 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return fmt.Errorf("object %q is not text", id)
|
||||
if object.Type == "TextObject" {
|
||||
value.WriteString(object.TextObject.Text())
|
||||
} else {
|
||||
path, err := e.compositePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := e.currentEditor.TargetRichText(page, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value.WriteString(text.Text())
|
||||
}
|
||||
value.WriteString(object.TextObject.Text())
|
||||
}
|
||||
if err := e.checkFontGlyphs(data, value.String()); err != nil {
|
||||
return err
|
||||
@@ -4917,19 +4962,113 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// formatText 批量修改选区格式,未指定的属性保持各自原值
|
||||
// 入参: args 页面索引、完整路径数组、格式JSON及可选显式重排标志
|
||||
// 返回: any 文档信息, error 校验或提交错误
|
||||
func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
||||
return e.changeObjects(func() error {
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patch ofdgo.TextFormatPatch
|
||||
if err := json.Unmarshal([]byte(args[2].String()), &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range []*string{patch.Color, patch.Background} {
|
||||
if value == nil || *value == "" {
|
||||
continue
|
||||
}
|
||||
var color ofdgo.FillColor
|
||||
if err := e.setEditorColor(&color, *value); err != nil {
|
||||
return err
|
||||
}
|
||||
*value = color.Value
|
||||
}
|
||||
if len(args) > 3 && args[3].Bool() {
|
||||
return e.currentEditor.ReflowTargetsText(args[0].Int(), paths, patch)
|
||||
}
|
||||
return e.currentEditor.FormatTargetsText(args[0].Int(), paths, patch)
|
||||
})
|
||||
}
|
||||
|
||||
// styleTextFonts 预检并原子导入各款式字体,批量替换文字字体及粗斜体
|
||||
// 入参: args 页面索引、完整路径、款式映射JSON、字体数据集合及重排标记
|
||||
// 返回: any 文档信息, error 字体或编辑错误
|
||||
func (e *businessEngine) styleTextFonts(args []platformValue) (any, error) {
|
||||
return e.changeObjects(func() error {
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var changes []ofdgo.TextFontChange
|
||||
if err := json.Unmarshal([]byte(args[2].Get("changes").String()), &changes); err != nil {
|
||||
return err
|
||||
}
|
||||
fonts := make(map[string]string)
|
||||
input := args[2].Get("fonts")
|
||||
for index := 0; index < input.Length(); index++ {
|
||||
entry := input.Index(index)
|
||||
data, err := e.bytesFromJS(entry.Get("data"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.checkFontGlyphs(data, entry.Get("text").String()); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := e.currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fonts[entry.Get("key").String()] = id
|
||||
}
|
||||
for index := range changes {
|
||||
if id, ok := fonts[changes[index].To.Font]; ok {
|
||||
changes[index].To.Font = id
|
||||
}
|
||||
}
|
||||
return e.currentEditor.StyleTargetsFonts(args[0].Int(), paths, changes, args[2].Get("reflow").Bool())
|
||||
})
|
||||
}
|
||||
|
||||
// layoutTexts 批量修改段落增量,未指定的选项沿用各对象原值
|
||||
// 入参: args 页面索引、完整路径数组和段落JSON
|
||||
// 返回: any 文档信息, error 校验或提交错误
|
||||
func (e *businessEngine) layoutTexts(args []platformValue) (any, error) {
|
||||
return e.changeObjects(func() error {
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patch struct {
|
||||
ofdgo.RichTextParagraph
|
||||
Wrap *bool `json:"wrap"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[2].String()), &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.currentEditor.LayoutTargetsText(args[0].Int(), paths, patch.RichTextParagraph, patch.Wrap)
|
||||
})
|
||||
}
|
||||
|
||||
// editorRichText 将富文本复合图元作为一个文本框提供给编辑器
|
||||
// 入参: item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称, local 局部编辑框, matrix 页面变换
|
||||
func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string, local ofdgo.Box, matrix ofdgo.Matrix) {
|
||||
if len(value.Runs) == 0 || object.CompositeGraphicUnit.Clips != nil {
|
||||
return
|
||||
}
|
||||
width, height := local.W, local.H
|
||||
width := local.W
|
||||
_, contentBounds, err := e.currentEditor.LayoutRichText(value, width)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame := matrix.TransformBox(contentBounds)
|
||||
height := contentBounds.Y + contentBounds.H
|
||||
format, layout := value.Runs[0].Format, value.Layout
|
||||
selection := contentBounds
|
||||
if !value.AutoWidth || layout.Wrap {
|
||||
selection = ofdgo.Box{W: width, H: height}
|
||||
}
|
||||
frame := matrix.TransformBox(selection)
|
||||
var content strings.Builder
|
||||
for _, run := range value.Runs {
|
||||
content.WriteString(run.Text)
|
||||
@@ -4946,7 +5085,7 @@ func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.Graphi
|
||||
item["letterSpacing"], item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LetterSpacing, layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent
|
||||
item["paragraphWidth"], item["bounds"] = width, e.editorBox(frame)
|
||||
item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, frame.H
|
||||
item["textFrame"] = map[string]any{"width": width, "height": height, "matrix": e.editorMatrix(matrix), "content": e.editorBox(contentBounds)}
|
||||
item["textFrame"] = map[string]any{"width": width, "height": height, "matrix": e.editorMatrix(matrix)}
|
||||
capabilities := item["capabilities"].(map[string]any)
|
||||
for _, key := range []string{"textContent", "rewriteText", "replaceFont", "reflow", "layoutKnown", "paint"} {
|
||||
capabilities[key] = true
|
||||
@@ -4960,19 +5099,58 @@ func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.Graphi
|
||||
}
|
||||
|
||||
// getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式
|
||||
// 入参: args 页码和对象标识
|
||||
// 返回: any 富文本快照, error 错误信息
|
||||
// 入参: args 页码、对象标识和可选显式重排标记
|
||||
// 返回: any 富文本快照及可选编辑框,无法提升时返回unsupported, error 读取或排版错误
|
||||
func (e *businessEngine) getRichText(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
value, err := e.currentEditor.RichText(args[0].Int(), args[1].String())
|
||||
path, err := e.compositePath(args[1].String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, frame, matrix, err := e.currentEditor.PrepareRichText(args[0].Int(), path, len(args) > 2 && args[2].Bool())
|
||||
var editError *ofdgo.EditError
|
||||
if len(args) > 2 && (errors.Is(err, ofdgo.ErrNotRichText) || errors.As(err, &editError) && editError.Code == ofdgo.EditLayoutRequired) {
|
||||
return map[string]any{"unsupported": true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(args) > 2 {
|
||||
return map[string]any{"model": e.browserRichText(value), "frame": map[string]any{
|
||||
"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(matrix),
|
||||
}}, nil
|
||||
}
|
||||
return e.browserRichText(value), nil
|
||||
}
|
||||
|
||||
// richTextFonts 提供跨文档文字复制所需的独立字体数据
|
||||
// 入参: args 富文本JSON
|
||||
// 返回: any 字体集合, error 读取错误
|
||||
func (e *businessEngine) richTextFonts(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
value, err := e.readRichText(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fonts, err := e.currentEditor.RichTextFonts(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]any, 0, len(fonts))
|
||||
for key, data := range fonts {
|
||||
_, data, _, _, err = ofdgo.FixFontDataAggressive(data, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, map[string]any{"key": key, "data": bytesToJS(data)})
|
||||
}
|
||||
return successResult(result), nil
|
||||
}
|
||||
|
||||
// browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式
|
||||
// 入参: value 库层富文本
|
||||
// 返回: map[string]any 文字分段和段落选项
|
||||
@@ -4990,8 +5168,18 @@ func (e *businessEngine) browserRichText(value ofdgo.RichText) map[string]any {
|
||||
"strikeout": format.Strikeout, "shadow": format.Shadow, "background": background,
|
||||
}}
|
||||
}
|
||||
paragraphs := make([]any, len(value.Paragraphs))
|
||||
for i, paragraph := range value.Paragraphs {
|
||||
entry := map[string]any{"align": paragraph.Align}
|
||||
for key, value := range map[string]*float64{"lineHeight": paragraph.LineHeight, "letterSpacing": paragraph.LetterSpacing, "leftIndent": paragraph.LeftIndent, "rightIndent": paragraph.RightIndent, "firstLineIndent": paragraph.FirstLineIndent} {
|
||||
if value != nil {
|
||||
entry[key] = *value
|
||||
}
|
||||
}
|
||||
paragraphs[i] = entry
|
||||
}
|
||||
layout := value.Layout
|
||||
return map[string]any{"runs": runs, "layout": map[string]any{
|
||||
return map[string]any{"autoWidth": value.AutoWidth, "runs": runs, "paragraphs": paragraphs, "layout": map[string]any{
|
||||
"autoBidi": layout.AutoBidi, "shape": layout.Shape,
|
||||
"shaping": map[string]any{
|
||||
"Bidi": layout.Shaping.Bidi, "Direction": layout.Shaping.Direction,
|
||||
@@ -5040,7 +5228,7 @@ func (e *businessEngine) readRichText(value platformValue) (ofdgo.RichText, erro
|
||||
}
|
||||
|
||||
// saveRichText 原子保存文字、样式和新增字体,空标识创建文本框
|
||||
// 入参: args 页码、标识、富文本JSON、新建边界、可选字体集合和父路径
|
||||
// 入参: args 页码、标识、富文本JSON、边界及重排标记、可选字体集合和父路径
|
||||
// 返回: any 文档信息及文字选区, error 错误信息
|
||||
func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
value, err := e.readRichText(args[2])
|
||||
@@ -5054,46 +5242,38 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
box := args[3]
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("reflow").Type() == platformTypeBoolean && box.Get("reflow").Bool() {
|
||||
path, err := e.compositePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.currentEditor.ReflowRichText(page, path, value)
|
||||
}
|
||||
if id != "" {
|
||||
path, err := e.compositePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
offset, width := 0.0, 0.0
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber {
|
||||
width = box.Get("frameWidth").Float()
|
||||
if box.Get("frameOffset").Type() == platformTypeNumber {
|
||||
offset = box.Get("frameOffset").Float()
|
||||
}
|
||||
}
|
||||
return e.currentEditor.UpdateTargetRichText(page, path, value, offset, width)
|
||||
}
|
||||
if len(args) > 5 && args[5].String() != "" {
|
||||
key := args[5].String()
|
||||
path, err := e.compositePath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
box := args[3]
|
||||
if id != "" {
|
||||
indexes, err := e.compositeIndexes(key, []string{id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
width, offset := 0.0, 0.0
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber {
|
||||
width = box.Get("frameWidth").Float()
|
||||
if box.Get("frameOffset").Type() == platformTypeNumber {
|
||||
offset = box.Get("frameOffset").Float()
|
||||
}
|
||||
}
|
||||
return e.currentEditor.UpdateCompositeRichText(page, path, indexes[0], value, offset, width)
|
||||
}
|
||||
index, err := e.currentEditor.AddCompositeRichText(page, path, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value)
|
||||
id = fmt.Sprintf("%s/%d", key, index)
|
||||
return err
|
||||
}
|
||||
if id != "" {
|
||||
if err := e.currentEditor.UpdateRichText(page, id, value); err != nil {
|
||||
return err
|
||||
}
|
||||
box := args[3]
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber {
|
||||
offset := 0.0
|
||||
if box.Get("frameOffset").Type() == platformTypeNumber {
|
||||
offset = box.Get("frameOffset").Float()
|
||||
}
|
||||
return e.currentEditor.ResizeRichTextFrame(page, id, offset, box.Get("frameWidth").Float())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
box := args[3]
|
||||
id, err = e.currentEditor.AddRichText(page, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value)
|
||||
return err
|
||||
})
|
||||
@@ -5114,35 +5294,24 @@ func (e *businessEngine) importRichTextFonts(value *ofdgo.RichText, fonts platfo
|
||||
if fonts.IsNull() || fonts.IsUndefined() {
|
||||
return nil
|
||||
}
|
||||
registered := make(map[string]string)
|
||||
data := make(map[string][]byte)
|
||||
for i := 0; i < fonts.Length(); i++ {
|
||||
entry := fonts.Index(i)
|
||||
key := entry.Get("key").String()
|
||||
if key == "" || registered[key] != "" {
|
||||
if key == "" || data[key] != nil {
|
||||
return fmt.Errorf("invalid rich text font key")
|
||||
}
|
||||
used := false
|
||||
for _, run := range value.Runs {
|
||||
used = used || run.Format.Font == key
|
||||
}
|
||||
if !used {
|
||||
continue
|
||||
}
|
||||
data, err := e.bytesFromJS(entry.Get("data"))
|
||||
bytes, err := e.bytesFromJS(entry.Get("data"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
font, err := e.currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registered[key] = font
|
||||
data[key] = bytes
|
||||
}
|
||||
for i := range value.Runs {
|
||||
if font := registered[value.Runs[i].Format.Font]; font != "" {
|
||||
value.Runs[i].Format.Font = font
|
||||
}
|
||||
imported, err := e.currentEditor.ImportRichText(*value, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*value = imported
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5239,32 +5408,21 @@ func (e *businessEngine) layoutText(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := e.currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return e.changeObjects(func() error {
|
||||
return e.layoutEditorText(args[0].Int(), ofdgo.ObjectPath{ID: args[1].String()}, args[2:])
|
||||
})
|
||||
}
|
||||
|
||||
// layoutEditorText 将正文和组合内文字的段落调整交给库统一处理
|
||||
// 入参: page 页面索引, path 完整对象路径, args 偏移、宽度及段落选项
|
||||
// 返回: error 排版或提交错误
|
||||
func (e *businessEngine) layoutEditorText(page int, path ofdgo.ObjectPath, args []platformValue) error {
|
||||
offset, width := 0.0, 0.0
|
||||
if !args[0].IsNull() {
|
||||
offset, width = args[0].Float(), args[1].Float()
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return nil, fmt.Errorf("object is not text")
|
||||
}
|
||||
if !args[2].IsNull() {
|
||||
object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value, _ := object.TextObject.TextLayout()
|
||||
if err := e.currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{AutoBidi: true, Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision := e.currentEditor.Revision()
|
||||
if err := e.currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.currentEditor.Revision() == revision {
|
||||
return e.editorSummary(), nil
|
||||
}
|
||||
return e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations)
|
||||
layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[2].Bool(), Align: args[3].String(), LineHeight: args[4].Float(), LetterSpacing: args[5].Float(), LeftIndent: args[6].Float(), RightIndent: args[7].Float(), FirstLineIndent: args[8].Float()}
|
||||
return e.currentEditor.LayoutTextFrame(page, path, layout, offset, width)
|
||||
}
|
||||
|
||||
// replaceImage 替换图片资源并更新预览
|
||||
@@ -5394,7 +5552,7 @@ func (e *businessEngine) copyObjects(args []platformValue) (any, error) {
|
||||
|
||||
// captureObjects 保存独立选区快照,不修改文档或历史
|
||||
// 入参: args 页面索引、对象路径数组和剪贴板标识
|
||||
// 返回: any 空结果, error 错误信息
|
||||
// 返回: any 捕获结果, error 错误信息
|
||||
func (e *businessEngine) captureObjects(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
@@ -5404,8 +5562,48 @@ func (e *businessEngine) captureObjects(args []platformValue) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
clipboard.token = args[2].String()
|
||||
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packet, err := e.currentEditor.PrepareTargets(e.operationContext(), args[0].Int(), paths, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.copiedObjects = clipboard
|
||||
return nil, nil
|
||||
e.portableObjects = &editorClipboard{token: clipboard.token, packet: packet}
|
||||
return successResult(nil), nil
|
||||
}
|
||||
|
||||
// exportObjects 按需压缩已准备的独立选区,切换文档或版本不影响结果
|
||||
// 入参: args 剪贴板标识
|
||||
// 返回: any 可跨文档导入的选区包, error 标识或写入错误
|
||||
func (e *businessEngine) exportObjects(args []platformValue) (any, error) {
|
||||
clipboard := e.portableObjects
|
||||
if clipboard == nil || clipboard.token != args[0].String() {
|
||||
return nil, fmt.Errorf("object clipboard is no longer available")
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if _, err := clipboard.packet.WriteToWithOptions(e.operationContext(), &output, ofdgo.WriteOptions{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.portableObjects = nil
|
||||
clipboard.packet = nil
|
||||
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes())}), nil
|
||||
}
|
||||
|
||||
// discardObjects 释放被新剪贴板替代的选区,不影响后续捕获
|
||||
// 入参: args 剪贴板标识
|
||||
// 返回: any 清理结果, error 始终为nil
|
||||
func (e *businessEngine) discardObjects(args []platformValue) (any, error) {
|
||||
token := args[0].String()
|
||||
if e.portableObjects != nil && e.portableObjects.token == token {
|
||||
e.portableObjects = nil
|
||||
}
|
||||
if e.copiedObjects != nil && e.copiedObjects.token == token {
|
||||
e.copiedObjects = nil
|
||||
}
|
||||
return successResult(nil), nil
|
||||
}
|
||||
|
||||
// pasteObjects 将当前文档的对象快照粘贴到目标页
|
||||
@@ -5423,6 +5621,38 @@ func (e *businessEngine) pasteObjects(args []platformValue) (any, error) {
|
||||
return editorSelectionInfo{info, ids}, err
|
||||
}
|
||||
|
||||
// importObjects 将独立选区包导入当前文档,来源标签无需保持打开
|
||||
// 入参: args 目标页、选区包和横纵位移
|
||||
// 返回: any 文档信息及新选区, error 读取或导入错误
|
||||
func (e *businessEngine) importObjects(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
data, err := e.bytesFromJS(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input, err := ofdgo.NewInput(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer input.Close()
|
||||
reader, err := ofdgo.NewReader(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
objects, annotations, err := e.currentEditor.ImportSelection(context.Background(), reader, args[0].Int(), ofdgo.ObjectImportOptions{DX: args[2].Float(), DY: args[3].Float(), AllowDecrypted: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range annotations {
|
||||
objects = append(objects, "annotation:"+id)
|
||||
}
|
||||
info, err := e.previewEditor(e.currentEditor, e.currentSession.Renderer.RenderAnnotations)
|
||||
return editorSelectionInfo{info, objects}, err
|
||||
}
|
||||
|
||||
// copyEditorObjects 复制快照并更新预览,返回新对象标识
|
||||
// 入参: page 目标页, objects 对象快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米
|
||||
// 返回: any 文档信息及新选区, error 错误信息
|
||||
@@ -6614,6 +6844,25 @@ func (e *businessEngine) changeAttachment(args []platformValue) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// changeSignature 移除当前文档签名,使用库层依赖检查和撤销历史
|
||||
// 入参: args 操作及签名标识
|
||||
// 返回: any 编辑状态, error 移除错误
|
||||
func (e *businessEngine) changeSignature(args []platformValue) (any, error) {
|
||||
if len(args) != 2 {
|
||||
return nil, fmt.Errorf("签名参数无效")
|
||||
}
|
||||
return e.changeAtomicObjects(func() error {
|
||||
switch args[0].String() {
|
||||
case "remove":
|
||||
return e.currentEditor.RemoveSignatureContext(e.operationContext(), args[1].String())
|
||||
case "clear":
|
||||
return e.currentEditor.ClearSignaturesContext(e.operationContext())
|
||||
default:
|
||||
return fmt.Errorf("签名操作无效")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态
|
||||
// 入参: args 字体标识、保存方式及可选字体数据
|
||||
// 返回: any 编辑状态, error 字体处理错误
|
||||
@@ -6766,7 +7015,7 @@ func (e *businessEngine) syncEditorPageIDs(index int, content *ofdgo.PageContent
|
||||
before.PathObject.ID = object.PathObject.ID
|
||||
before.ImageObject.ID = object.ImageObject.ID
|
||||
before.CompositeGraphicUnit.ID = object.CompositeGraphicUnit.ID
|
||||
delete(e.currentSession.textCache, index)
|
||||
e.currentSession.textCache.Delete(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6809,11 +7058,12 @@ func (e *businessEngine) previewEditor(editor *ofdgo.Editor, annotations bool) (
|
||||
session.pageCache[index] = page
|
||||
}
|
||||
}
|
||||
for _, index := range e.currentSession.textOrder {
|
||||
e.currentSession.textCache.Range(func(index int, text *ofdgo.PageText) bool {
|
||||
if !slices.Contains(changed, index) {
|
||||
session.cachePageText(index, e.currentSession.textCache[index])
|
||||
session.cachePageText(index, text)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for index, box := range e.currentSession.boxCache {
|
||||
if !slices.Contains(changed, index) {
|
||||
session.boxCache[index] = box
|
||||
@@ -7841,12 +8091,14 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoMatchFontFiles": e.matchFontFiles,
|
||||
"ofdgoChangeFontBinding": e.changeFontBinding,
|
||||
"ofdgoFontFaces": e.fontFaces,
|
||||
"ofdgoFontMenu": e.fontMenu,
|
||||
"ofdgoFontFace": e.fontFace,
|
||||
"ofdgoCreateDocument": e.createDocument,
|
||||
"ofdgoImportImages": e.importImages,
|
||||
"ofdgoEditDocument": e.editDocument,
|
||||
"ofdgoUpdateInfo": e.updateInfo,
|
||||
"ofdgoChangeAttachment": e.changeAttachment,
|
||||
"ofdgoChangeSignature": e.changeSignature,
|
||||
"ofdgoChangePage": e.changePage,
|
||||
"ofdgoBatchPages": e.batchPages,
|
||||
"ofdgoChangeOutline": e.changeOutline,
|
||||
@@ -7872,10 +8124,14 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoPreviewTextSelection": e.previewTextSelection,
|
||||
"ofdgoUpdateTextSelection": e.updateTextSelection,
|
||||
"ofdgoStyleText": e.styleText,
|
||||
"ofdgoFormatText": e.formatText,
|
||||
"ofdgoStyleTextFonts": e.styleTextFonts,
|
||||
"ofdgoGetRichText": e.getRichText,
|
||||
"ofdgoRichTextFonts": e.richTextFonts,
|
||||
"ofdgoSaveRichText": e.saveRichText,
|
||||
"ofdgoPreviewRichText": e.previewRichText,
|
||||
"ofdgoCheckTextFont": e.checkTextFont,
|
||||
"ofdgoFontPreferenceOrder": e.fontPreferenceOrder,
|
||||
"ofdgoInsertImage": e.insertImage,
|
||||
"ofdgoInsertShape": e.insertShape,
|
||||
"ofdgoUpdatePathStyle": e.updatePathStyle,
|
||||
@@ -7887,6 +8143,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoReshapeLine": e.reshapeLine,
|
||||
"ofdgoResetImageCrop": e.resetImageCrop,
|
||||
"ofdgoLayoutText": e.layoutText,
|
||||
"ofdgoLayoutTexts": e.layoutTexts,
|
||||
"ofdgoTransformObjects": e.transformObjects,
|
||||
"ofdgoCompositeObjects": e.compositeObjects,
|
||||
"ofdgoChangeCompositeObjects": e.changeCompositeObjects,
|
||||
@@ -7896,7 +8153,10 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoEraseObjectsPath": e.eraseObjectsPath,
|
||||
"ofdgoCopyObjects": e.copyObjects,
|
||||
"ofdgoCaptureObjects": e.captureObjects,
|
||||
"ofdgoExportObjects": e.exportObjects,
|
||||
"ofdgoDiscardObjects": e.discardObjects,
|
||||
"ofdgoPasteObjects": e.pasteObjects,
|
||||
"ofdgoImportObjects": e.importObjects,
|
||||
"ofdgoOrderObjects": e.orderObjects,
|
||||
"ofdgoDistributeObjects": e.distributeObjects,
|
||||
"ofdgoRotateObjects": e.rotateObjects,
|
||||
@@ -7930,6 +8190,8 @@ func (e *businessEngine) operationContext() context.Context {
|
||||
func failureResult(err error) apiResult {
|
||||
result := apiResult{Error: err.Error()}
|
||||
switch {
|
||||
case errors.Is(err, ofdgo.ErrSignatureDependency):
|
||||
result.Code, result.Error = "signatureDependency", "存在签名或版本依赖,无法移除"
|
||||
case errors.Is(err, ofdgo.ErrIncompleteDocument):
|
||||
result.Code, result.Error = "documentPending", "文档尚未准备完成"
|
||||
case errors.Is(err, ofdgo.ErrPrintDenied):
|
||||
|
||||
@@ -227,8 +227,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
|
||||
if result != nil && result["ok"] == true && ctx.Err() == nil {
|
||||
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
|
||||
clear(worker.pageCache)
|
||||
clear(worker.textCache)
|
||||
worker.textOrder = nil
|
||||
worker.textCache.Clear()
|
||||
clear(worker.svgFonts)
|
||||
e.renderIdle = append(e.renderIdle, worker)
|
||||
}
|
||||
@@ -380,7 +379,7 @@ func (e *NativeEngine) Close() error {
|
||||
if e.pendingImport != nil {
|
||||
err = errors.Join(err, e.pendingImport.Close())
|
||||
}
|
||||
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil
|
||||
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.portableObjects, e.copiedStyle = nil, nil, nil, nil, nil, nil
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -431,7 +430,7 @@ func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) {
|
||||
}
|
||||
return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS,
|
||||
pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...),
|
||||
textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), editing: source.editing}, nil
|
||||
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes), svgFonts: make(map[string][]byte), editing: source.editing}, nil
|
||||
}
|
||||
|
||||
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
|
||||
|
||||
@@ -1049,7 +1049,7 @@ func (h *Host) readFonts() {
|
||||
continue
|
||||
}
|
||||
for _, face := range faces {
|
||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file})
|
||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file})
|
||||
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
||||
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
||||
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||
|
||||
+4
-9
@@ -248,7 +248,6 @@ func (e *Editor) PageContext(ctx context.Context, index int) (*PageContent, erro
|
||||
}
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
object.TextObject.layout = nil
|
||||
layer.TextObject = append(layer.TextObject, object.TextObject)
|
||||
case "PathObject":
|
||||
layer.PathObject = append(layer.PathObject, object.PathObject)
|
||||
@@ -631,7 +630,9 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
|
||||
}
|
||||
object, err := cloneEditorObject(layer.Objects[index])
|
||||
object.origin = e.objectOrigin(id)
|
||||
e.attachRichTextSnapshot(page, &object)
|
||||
if err := e.attachRichTextSnapshot(page, &object); err != nil {
|
||||
return GraphicObject{}, err
|
||||
}
|
||||
return object, err
|
||||
}
|
||||
|
||||
@@ -799,13 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := LayoutText(obj, value, options, metrics); err != nil {
|
||||
return err
|
||||
}
|
||||
if obj.layout == nil && e.objectOrigin(obj.ID) != nil {
|
||||
obj.layout = &textLayout{value: obj.Text(), options: options}
|
||||
}
|
||||
return nil
|
||||
return LayoutText(obj, value, options, metrics)
|
||||
}
|
||||
|
||||
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
||||
|
||||
+27
-2
@@ -32,6 +32,7 @@ type AnnotationSelection struct {
|
||||
data [][]byte
|
||||
resources []string
|
||||
states map[string]map[string]editorCompositeState
|
||||
richText map[int]*richTextSnapshot
|
||||
}
|
||||
|
||||
// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立
|
||||
@@ -317,7 +318,7 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState)}
|
||||
selection := &AnnotationSelection{editor: e, document: e.documentIdentity(), states: make(map[string]map[string]editorCompositeState), richText: make(map[int]*richTextSnapshot)}
|
||||
seen := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
key := editorResourceID(id)
|
||||
@@ -336,6 +337,17 @@ func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelectio
|
||||
if !editorXMLCopyable(root) {
|
||||
return nil, fmt.Errorf("annotation contains unsupported identifiers")
|
||||
}
|
||||
annotation, err := e.Annotation(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := e.captureRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshot != nil {
|
||||
selection.richText[len(selection.data)] = snapshot
|
||||
}
|
||||
selection.data = append(selection.data, data)
|
||||
selection.states[key] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+key])
|
||||
}
|
||||
@@ -452,7 +464,20 @@ func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx,
|
||||
break
|
||||
}
|
||||
}
|
||||
return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})})
|
||||
if err := edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, id := range result {
|
||||
if snapshot := selection.richText[i]; snapshot != nil {
|
||||
annotation, err := edit.Annotation(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := edit.putRichTextSnapshot(page, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: annotation.Appearance.Objects}}, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -96,7 +96,9 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
|
||||
return nil, err
|
||||
}
|
||||
objects[i].origin = e.objectOrigin(editorObjectID(object))
|
||||
e.attachRichTextSnapshot(page, &objects[i])
|
||||
if err := e.attachRichTextSnapshot(page, &objects[i]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
@@ -135,7 +137,11 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.putRichTextMetadata(page, ids[i], meta, -1); err != nil {
|
||||
object, err := e.Object(page, ids[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.putRichTextSnapshot(page, object, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +334,9 @@ func (n *editorCompositeNode) setStates(states map[string]editorCompositeState)
|
||||
}
|
||||
}
|
||||
visit(n.node)
|
||||
n.object.TextObject.layout = n.states[editorObjectID(n.object)].layout
|
||||
if state, ok := n.states[editorObjectID(n.object)]; ok {
|
||||
n.object.TextObject.layout = state.layout
|
||||
}
|
||||
}
|
||||
|
||||
// setState 替换当前对象的会话记录并计入本次事务
|
||||
|
||||
@@ -17,7 +17,6 @@ package ofdgo
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -37,7 +36,7 @@ type CompositeSelection struct {
|
||||
containers []int
|
||||
nodes []*editorCompositeNode
|
||||
source *editorSourcePage
|
||||
richText map[int]richTextMetadata
|
||||
richText map[int]*richTextSnapshot
|
||||
}
|
||||
|
||||
// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪
|
||||
@@ -75,7 +74,7 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
||||
slices.Sort(indexes)
|
||||
var owner *editorCompositeNode
|
||||
var container *editorXML
|
||||
selection := &CompositeSelection{richText: make(map[int]richTextMetadata), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
|
||||
selection := &CompositeSelection{richText: make(map[int]*richTextSnapshot), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}}
|
||||
if path.Annotation == "" {
|
||||
if origin := e.objectOrigin(path.ID); origin != nil {
|
||||
selection.source = origin.page
|
||||
@@ -99,11 +98,13 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in
|
||||
copy := *node
|
||||
copy.owner, copy.span = nil, nil
|
||||
selection.nodes = append(selection.nodes, ©)
|
||||
if entry, err := e.richTextEntry(page, editorObjectID(node.object)); err == nil {
|
||||
selection.richText[j] = cloneEditorData(entry.metadata)
|
||||
} else if !errors.Is(err, ErrNotRichText) {
|
||||
snapshot, err := e.captureRichTextSnapshot(page, node.object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshot != nil {
|
||||
selection.richText[j] = snapshot
|
||||
}
|
||||
}
|
||||
scope := root.loadedScope(path.Children)
|
||||
for scope != owner {
|
||||
@@ -337,21 +338,16 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu
|
||||
return err
|
||||
}
|
||||
object.state = copy.states[editorObjectID(copy.object)]
|
||||
object.TextObject.layout = object.state.layout
|
||||
if object.state.layout != nil {
|
||||
object.TextObject.layout = object.state.layout
|
||||
}
|
||||
copy.object.TextObject.layout = object.TextObject.layout
|
||||
if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
|
||||
object.CompositeGraphicUnit.states = maps.Clone(copy.states)
|
||||
copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states
|
||||
}
|
||||
object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object}
|
||||
if meta, ok := selection.richText[i]; ok {
|
||||
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects)
|
||||
object.richText = &meta
|
||||
}
|
||||
object.richText = selection.richText[i]
|
||||
objects = append(objects, object)
|
||||
remaps = append(remaps, copy.remaps...)
|
||||
}
|
||||
|
||||
@@ -251,7 +251,9 @@ func (e *Editor) ungroupCompositeNode(renderer *Renderer, node *editorCompositeN
|
||||
}
|
||||
member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}}
|
||||
member.object.state = states[editorObjectID(member.object)]
|
||||
member.object.TextObject.layout = member.object.state.layout
|
||||
if member.object.state.layout != nil {
|
||||
member.object.TextObject.layout = member.object.state.layout
|
||||
}
|
||||
member.object.CompositeGraphicUnit.states = states
|
||||
member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object}
|
||||
objects[i] = member.object
|
||||
|
||||
@@ -17,7 +17,6 @@ package ofdgo
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -46,7 +45,7 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
||||
return nil, fmt.Errorf("copy requires finite offsets")
|
||||
}
|
||||
var result []int
|
||||
metadata := make(map[int]richTextMetadata)
|
||||
metadata := make(map[int]*richTextSnapshot)
|
||||
err := e.Transaction(func(e *Editor) error {
|
||||
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||
container := nodes[0].span.parent
|
||||
@@ -57,13 +56,12 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
|
||||
}
|
||||
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
|
||||
for i, node := range nodes {
|
||||
if node.object.Type == "CompositeObject" {
|
||||
entry, err := e.richTextEntry(page, editorObjectID(node.object))
|
||||
if err == nil {
|
||||
metadata[i] = entry.metadata
|
||||
} else if !errors.Is(err, ErrNotRichText) {
|
||||
return err
|
||||
}
|
||||
snapshot, err := e.captureRichTextSnapshot(page, node.object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot != nil {
|
||||
metadata[i] = snapshot
|
||||
}
|
||||
}
|
||||
data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy)
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排
|
||||
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按排版选项重排
|
||||
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
||||
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
||||
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
||||
@@ -29,7 +29,7 @@ func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value
|
||||
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
||||
}
|
||||
|
||||
// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位
|
||||
// StyleCompositeText 批量修改内部文字字体、字号或颜色,已知段落重排,原文保留定位
|
||||
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
||||
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
||||
// 返回: error 错误信息
|
||||
@@ -38,7 +38,7 @@ func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, st
|
||||
}
|
||||
|
||||
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
||||
// 段落选项仅保留在编辑会话中,撤销重做随对象恢复,保存为标准TextCode定位
|
||||
// 段落选项随标准TextCode定位保存,撤销重做及重新打开时恢复
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
|
||||
@@ -80,6 +80,14 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
||||
object := cloneEditorData(node.object)
|
||||
text := cloneEditorData(member.Object.TextObject)
|
||||
state := node.states[editorObjectID(node.object)]
|
||||
if state.layout == nil && text.layout != nil {
|
||||
x, _, err := e.richPlainOffset(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
y, _ := strconv.ParseFloat(text.TextCode[0].Y, 64)
|
||||
state.layout, state.origin = text.layout, [2]float64{x, y}
|
||||
}
|
||||
content, layout := text.TextLayout()
|
||||
original := content
|
||||
if style.Font != "" {
|
||||
@@ -121,7 +129,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
||||
}
|
||||
object.TextObject.TextCode = text.TextCode
|
||||
object.TextObject.CGTransform = text.CGTransform
|
||||
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && state.layout.options.Shape && style.Font != "" && style.Font != object.TextObject.Font {
|
||||
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && style.Font != "" && style.Font != object.TextObject.Font {
|
||||
if style.Size != 0 {
|
||||
text.Size = style.Size
|
||||
}
|
||||
@@ -140,7 +148,10 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
||||
}
|
||||
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
||||
object.TextObject.CGTransform = text.CGTransform
|
||||
state.layout = &textLayout{value: content, options: layout}
|
||||
if frame != nil {
|
||||
text.layout.autoWidth = false
|
||||
}
|
||||
state.layout = text.layout
|
||||
}
|
||||
if style.Font != "" && style.Font != object.TextObject.Font {
|
||||
if err := e.prepareText(&text); err != nil {
|
||||
@@ -161,6 +172,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
||||
}
|
||||
object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor)
|
||||
}
|
||||
object.TextObject.layout = state.layout
|
||||
if err := node.update(object); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -968,7 +968,7 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
|
||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.Name, attr) {
|
||||
uncertain = true
|
||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins
|
||||
return nil
|
||||
}
|
||||
for _, attr := range node.attrs {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.name, attr) {
|
||||
scan.unsafe = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,10 @@ func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) {
|
||||
state := object.state
|
||||
if object.Type == "TextObject" && object.TextObject.layout != nil {
|
||||
state.layout = object.TextObject.layout
|
||||
state.origin[0], _ = strconv.ParseFloat(object.TextObject.TextCode[0].X, 64)
|
||||
state.origin[0], _, err = e.richPlainOffset(object.TextObject)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64)
|
||||
}
|
||||
states[editorObjectID(object)] = state
|
||||
|
||||
+94
-29
@@ -112,63 +112,107 @@ func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, pag
|
||||
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
|
||||
// 返回: []string 新对象标识, error 校验、迁移或取消错误
|
||||
func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) {
|
||||
objects, _, err := e.importSelection(ctx, source, sourcePage, ids, nil, page, options, true)
|
||||
return objects, err
|
||||
}
|
||||
|
||||
// ImportSelection 导入独立单页选区包,重映射对象和资源标识,一次撤销恢复全部
|
||||
// 入参: ctx 取消上下文, source 单页选区阅读器, page 目标页, options 位移及导入配置
|
||||
// 返回: []string 正文标识, []string 注解标识, error 校验或迁移错误
|
||||
func (e *Editor) ImportSelection(ctx context.Context, source *Reader, page int, options ObjectImportOptions) ([]string, []string, error) {
|
||||
doc, err := source.DocContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(doc.Pages.Page) != 1 {
|
||||
return nil, nil, fmt.Errorf("selection requires exactly one page")
|
||||
}
|
||||
origin, err := source.EditorContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
content, err := origin.page(0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var objects, annotations []string
|
||||
for _, layer := range content.Content.Layer {
|
||||
for _, object := range layer.Objects {
|
||||
objects = append(objects, editorObjectID(object))
|
||||
}
|
||||
}
|
||||
notes, err := origin.Annotations(0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, note := range notes {
|
||||
annotations = append(annotations, note.ID)
|
||||
}
|
||||
return e.importSelection(ctx, origin, 0, objects, annotations, page, options, true)
|
||||
}
|
||||
|
||||
// importSelection 迁移正文与注解,共用资源映射并保持来源绘制顺序
|
||||
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, options 导入配置, strictLinks 拒绝跨页链接
|
||||
// 返回: []string 正文标识, []string 注解标识, error 迁移或提交错误
|
||||
func (e *Editor) importSelection(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, options ObjectImportOptions, strictLinks bool) ([]string, []string, error) {
|
||||
progress := editorProgress(options.OnProgress).withContext(ctx)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !finite(options.DX) || !finite(options.DY) {
|
||||
return nil, fmt.Errorf("import requires finite offsets")
|
||||
return nil, nil, fmt.Errorf("import requires finite offsets")
|
||||
}
|
||||
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
|
||||
return nil, ErrEncryptionPolicyRequired
|
||||
return nil, nil, ErrEncryptionPolicyRequired
|
||||
}
|
||||
objects, err := source.Objects(sourcePage, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
target, err := e.page(page)
|
||||
if err != nil || len(objects) == 0 {
|
||||
return nil, err
|
||||
if err != nil || len(objects) == 0 && len(annotations) == 0 {
|
||||
return nil, nil, err
|
||||
}
|
||||
if source == e {
|
||||
if err := progress.report("commit", 0, 0); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
|
||||
copied, err := e.CopyObjectsContext(ctx, page, objects, options.DX, options.DY)
|
||||
return copied, nil, err
|
||||
}
|
||||
reader, err := source.readerContext(ctx, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
doc, err := reader.Doc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
ref := doc.Pages.Page[sourcePage]
|
||||
base, migration, err := e.prepareImport(ctx, reader, doc, map[string]bool{ref.ID: true}, false, progress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, true
|
||||
migration.ids[editorImportKey(ref.ID)], migration.strictLinks = target.ID, strictLinks
|
||||
pageName := reader.ResPath(ref.BaseLoc)
|
||||
content, err := reader.PageContentByIndex(sourcePage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, value := range content.PageRes {
|
||||
name, err := migration.resolve(pageName, "", value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := migration.resourceIndex(name); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
layers := make([][]byte, len(objects))
|
||||
for i, object := range objects {
|
||||
if err := progress.report("objects", i, len(objects)); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
var data []byte
|
||||
var attrs ofdAttrs
|
||||
@@ -187,32 +231,48 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
||||
data, err = editorObjectXML(object)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err = editorXMLContainer("Layer", attrs, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
notes := &AnnotationSelection{editor: e, document: e.documentIdentity()}
|
||||
for _, id := range annotations {
|
||||
data, err := source.annotationXML(sourcePage, id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
notes.data = append(notes.data, data)
|
||||
}
|
||||
if err := migration.encodeResources(); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, resource := range e.resources {
|
||||
delete(migration.files, resource.name)
|
||||
}
|
||||
next, err := migration.merge(base, nil, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
var copied []string
|
||||
var copied, copiedNotes []string
|
||||
err = e.Transaction(func(editor *Editor) error {
|
||||
if err := progress.report("commit", 0, 0); err != nil {
|
||||
return err
|
||||
@@ -236,13 +296,13 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
||||
return err
|
||||
}
|
||||
object := layer.Objects[0]
|
||||
object.TextObject.layout = objects[i].TextObject.layout
|
||||
object.state = objects[i].state
|
||||
object.CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
|
||||
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imported[i].TextObject.layout = objects[i].TextObject.layout
|
||||
imported[i].state = objects[i].state
|
||||
imported[i].CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
|
||||
imported[i].origin = &editorObjectOrigin{editor: editor, document: editor.documentIdentity(), data: data, node: root.child("Layer").children[0], object: object}
|
||||
}
|
||||
copied, err = editor.CopyObjectsContext(ctx, page, imported, options.DX, options.DY)
|
||||
@@ -253,12 +313,17 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc
|
||||
for i, object := range objects {
|
||||
orderedIDs[i] = editorObjectID(object)
|
||||
}
|
||||
return editor.copyRichTextMetadata(source, sourcePage, orderedIDs, page, copied)
|
||||
notes.document = editor.documentIdentity()
|
||||
copiedNotes, err = editor.PasteAnnotations(page, notes, options.DX, options.DY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return editor.copyRichTextMetadata(ctx, source, sourcePage, orderedIDs, annotations, page, copied, copiedNotes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return copied, err
|
||||
return copied, copiedNotes, err
|
||||
}
|
||||
|
||||
// ImportPages 将来源当前文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
||||
|
||||
@@ -76,7 +76,7 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := editorXMLNamespacePrefix(root)
|
||||
prefix := editorXMLNamespacePrefix(root, "ofd")
|
||||
var patches []editorXMLPatch
|
||||
var visit func(*editorXML, bool) error
|
||||
visit = func(node *editorXML, declare bool) error {
|
||||
@@ -152,9 +152,9 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀
|
||||
// 入参: node 节点
|
||||
// 入参: node 节点, base 首选前缀
|
||||
// 返回: string 可用前缀
|
||||
func editorXMLNamespacePrefix(node *editorXML) string {
|
||||
func editorXMLNamespacePrefix(node *editorXML, base string) string {
|
||||
used := make(map[string]bool)
|
||||
collect := func(current *editorXML) {
|
||||
for _, attr := range current.attrs {
|
||||
@@ -174,9 +174,9 @@ func editorXMLNamespacePrefix(node *editorXML) string {
|
||||
}
|
||||
}
|
||||
visit(node)
|
||||
prefix := "ofd"
|
||||
prefix := base
|
||||
for index := 1; used[prefix]; index++ {
|
||||
prefix = "ofd" + strconv.Itoa(index)
|
||||
prefix = base + strconv.Itoa(index)
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
||||
}
|
||||
}
|
||||
for _, attr := range token.Attr {
|
||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace {
|
||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textMetadataAttribute(token.Name, attr) {
|
||||
continue
|
||||
}
|
||||
custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags"
|
||||
|
||||
+430
-107
@@ -25,6 +25,7 @@ import (
|
||||
"image/color"
|
||||
"io"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,12 @@ type richTextMetadata struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// richTextSnapshot 保存对象及嵌套成员的不可变编辑语义,成员按绘制顺序定位
|
||||
type richTextSnapshot struct {
|
||||
metadata *richTextMetadata
|
||||
children map[int]*richTextSnapshot
|
||||
}
|
||||
|
||||
// richTextEntry 保存标引入口及延迟报告的损坏诊断
|
||||
type richTextEntry struct {
|
||||
index int
|
||||
@@ -62,6 +69,7 @@ type richTextIndex struct {
|
||||
}
|
||||
|
||||
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
||||
// 按给定宽度排版,高度随内容自适应
|
||||
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
||||
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
||||
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
||||
@@ -89,12 +97,30 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换
|
||||
// UpdateRichText 原子替换富文本内容,保留对象标识、段落宽度及页面变换,高度随内容调整
|
||||
// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源
|
||||
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
||||
// 返回: error 识别、排版或提交错误
|
||||
func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
|
||||
return e.updateRichTextFrame(page, id, value, 0, 0)
|
||||
return e.updateRichTextFrame(page, id, value, 0, 0, false)
|
||||
}
|
||||
|
||||
// UpdateTargetRichText 一次提交正文或组合内富文本的内容和局部宽度,保留页面变换
|
||||
// 入参: page 页面索引, path 完整对象路径, value 富文本, offset 局部偏移, width 宽度,零值保留宽度模式
|
||||
// 返回: error 路径、尺寸或排版错误
|
||||
func (e *Editor) UpdateTargetRichText(page int, path ObjectPath, value RichText, offset, width float64) error {
|
||||
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||
return fmt.Errorf("invalid text frame")
|
||||
}
|
||||
if len(path.Children) == 0 {
|
||||
if path.Annotation != "" {
|
||||
return ErrNotRichText
|
||||
}
|
||||
return e.updateRichTextFrame(page, path.ID, value, offset, width, false)
|
||||
}
|
||||
index := path.Children[len(path.Children)-1]
|
||||
path.Children = path.Children[:len(path.Children)-1]
|
||||
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||
}
|
||||
|
||||
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||
@@ -108,29 +134,28 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.updateRichTextFrame(page, id, value, offset, width)
|
||||
return e.updateRichTextFrame(page, id, value, offset, width, false)
|
||||
}
|
||||
|
||||
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度
|
||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度
|
||||
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时沿用原宽度模式
|
||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排原文
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error {
|
||||
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64, reflow bool) error {
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
old, err := e.Object(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := e.RichText(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frame, matrix, err := e.RichTextFrame(page, id)
|
||||
current, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if width > 0 {
|
||||
value.AutoWidth = false
|
||||
}
|
||||
if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) {
|
||||
return nil
|
||||
}
|
||||
@@ -255,19 +280,8 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
if object.Type == "TextObject" {
|
||||
if _, err := e.promoteRichText(page, object); err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
frame, err := object.TextObject.TextFrame()
|
||||
if err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
box, _ := creationBox(object.TextObject.Boundary)
|
||||
dx, dy, err := e.richPlainOffset(object.TextObject)
|
||||
if err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
return Box{W: frame.W, H: frame.H}, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(frame.X+dx, frame.Y+dy)), nil
|
||||
_, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, false)
|
||||
return frame, matrix, err
|
||||
}
|
||||
if object.Type != "CompositeObject" {
|
||||
return Box{}, Matrix{}, ErrNotRichText
|
||||
@@ -331,17 +345,34 @@ func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText,
|
||||
}
|
||||
|
||||
// UpdateCompositeRichText 原子重排内部富文本,保留父变换并隔离共享实例
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值保留
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值沿用原宽度模式
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error {
|
||||
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||
}
|
||||
|
||||
// updateCompositeRichText 统一处理内部文字的安全提升与显式重排
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
|
||||
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||
return fmt.Errorf("invalid rich text frame")
|
||||
}
|
||||
if width > 0 {
|
||||
value.AutoWidth = false
|
||||
}
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
member, err := e.CompositeObject(page, path, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if member.Object.Type == "TextObject" {
|
||||
return e.updateCompositePlainRichText(page, path, index, value, offset, width, reflow)
|
||||
}
|
||||
var metadata richTextMetadata
|
||||
entryIndex := -1
|
||||
changed := false
|
||||
err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||
err = e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||
node := nodes[0]
|
||||
current, frame, _, err := e.CompositeRichText(page, members[0])
|
||||
if err != nil {
|
||||
@@ -388,7 +419,7 @@ func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, v
|
||||
if err != nil || !changed {
|
||||
return err
|
||||
}
|
||||
member, err := e.CompositeObject(page, path, index)
|
||||
member, err = e.CompositeObject(page, path, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -416,12 +447,12 @@ func (e *Editor) putCompositeRichTextMetadata(page int, object GraphicObject, me
|
||||
// putCompositeRichTextCopies 一次读取复制后的选区,按对象顺序恢复富文本标引
|
||||
// 入参: page 页面索引, path 父路径, indexes 新成员序号, metadata 选区位置对应的编辑语义
|
||||
// 返回: error 读取或提交错误
|
||||
func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]richTextMetadata) error {
|
||||
func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]*richTextSnapshot) error {
|
||||
if len(metadata) == 0 {
|
||||
return nil
|
||||
}
|
||||
selected := make([]int, 0, len(metadata))
|
||||
values := make([]richTextMetadata, 0, len(metadata))
|
||||
values := make([]*richTextSnapshot, 0, len(metadata))
|
||||
for i, index := range indexes {
|
||||
if meta, ok := metadata[i]; ok {
|
||||
selected = append(selected, index)
|
||||
@@ -433,14 +464,14 @@ func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes [
|
||||
return err
|
||||
}
|
||||
for i, member := range members {
|
||||
if err := e.putCompositeRichTextMetadata(page, member.Object, values[i], -1); err != nil {
|
||||
if err := e.putRichTextSnapshot(page, member.Object, values[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引
|
||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引,高度随排版内容自适应
|
||||
// 入参: box 页面或局部编辑框, value 富文本
|
||||
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
||||
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
||||
@@ -454,8 +485,9 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
||||
if err != nil {
|
||||
return GraphicObject{}, richTextMetadata{}, err
|
||||
}
|
||||
box.W, box.H = result.width, result.bounds.Y+result.bounds.H
|
||||
padX, padY := -min(0, result.bounds.X), -min(0, result.bounds.Y)
|
||||
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: max(box.H, result.bounds.Y+result.bounds.H) + padY}
|
||||
extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: box.H + padY}
|
||||
if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 {
|
||||
return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range")
|
||||
}
|
||||
@@ -478,7 +510,7 @@ func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTe
|
||||
if err != nil {
|
||||
return GraphicObject{}, richTextMetadata{}, err
|
||||
}
|
||||
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: max(box.H, result.bounds.Y+result.bounds.H)}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
|
||||
meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: box.H}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)}
|
||||
for i := range meta.Value.Runs {
|
||||
meta.Value.Runs[i].Format.Font = ""
|
||||
}
|
||||
@@ -659,25 +691,31 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata,
|
||||
// 入参: page 页面索引, object 普通文字快照
|
||||
// 返回: RichText 单片段, error 不安全提升错误
|
||||
func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) {
|
||||
t := object.TextObject
|
||||
unsupported := func() (RichText, error) {
|
||||
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||
}
|
||||
layer, _, err := e.findObject(page, t.ID)
|
||||
layer, _, err := e.findObject(page, object.TextObject.ID)
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
if layer.DrawParam != "" || t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
||||
return unsupported()
|
||||
if origin := e.objectOrigin(object.TextObject.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||
}
|
||||
if origin := e.objectOrigin(t.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||
return unsupported()
|
||||
}
|
||||
frame, err := t.TextFrame()
|
||||
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
return e.promoteTextValue(object.TextObject)
|
||||
}
|
||||
|
||||
// promoteTextValue 校验普通文字是否可无损提升,保留明确段落或可验证单行布局
|
||||
// 入参: t 有效文字样式与局部定位
|
||||
// 返回: RichText 单片段, error 布局或格式错误
|
||||
func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
unsupported := func() (RichText, error) {
|
||||
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||
}
|
||||
if t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
||||
return unsupported()
|
||||
}
|
||||
if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) {
|
||||
if _, err := t.TextFrame(); err != nil {
|
||||
return unsupported()
|
||||
}
|
||||
value, layout := t.TextLayout()
|
||||
@@ -693,7 +731,7 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
||||
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
||||
return unsupported()
|
||||
}
|
||||
if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||
if !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||
return unsupported()
|
||||
}
|
||||
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
||||
@@ -721,22 +759,20 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
||||
}
|
||||
color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
|
||||
}
|
||||
result := RichText{Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
|
||||
result := RichText{AutoWidth: t.layout == nil || t.layout.autoWidth, Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout}
|
||||
return result, result.Validate()
|
||||
}
|
||||
|
||||
// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局
|
||||
// richPlainOffset 计算原首行相对标准排版的位移,保留既有段落和组合内基线
|
||||
// 入参: text 已校验的普通文字
|
||||
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
||||
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||
if text.layout != nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
if len(text.TextCode) != 1 {
|
||||
return 0, 0, fmt.Errorf("positioned text requires explicit layout")
|
||||
if len(text.TextCode) == 0 {
|
||||
return 0, 0, fmt.Errorf("text codes are empty")
|
||||
}
|
||||
value, layout := text.TextLayout()
|
||||
check := text
|
||||
if err := e.LayoutText(&check, text.Text(), TextLayout{}); err != nil {
|
||||
if err := e.LayoutText(&check, value, layout); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
||||
@@ -751,32 +787,60 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return x, y - baseline, nil
|
||||
origin, err := strconv.ParseFloat(check.TextCode[0].X, 64)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return x - origin, y - baseline, nil
|
||||
}
|
||||
|
||||
// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用
|
||||
// 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识
|
||||
// 返回: error 元数据损坏或提交错误
|
||||
func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) error {
|
||||
// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
|
||||
// 入参: ctx 取消上下文, source 来源编辑器, sourcePage 来源页, ids 正文标识, annotations 注解标识, page 目标页, copied 新正文标识, copiedNotes 新注解标识
|
||||
// 返回: error 元数据损坏、结构不一致或提交错误
|
||||
func (e *Editor) copyRichTextMetadata(ctx context.Context, source *Editor, sourcePage int, ids, annotations []string, page int, copied, copiedNotes []string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
metadata := source.richTextMetadataIndex()
|
||||
if metadata.err != nil || len(metadata.entries) == 0 {
|
||||
return metadata.err
|
||||
}
|
||||
copyObject := func(before, after GraphicObject) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := source.captureRichTextSnapshot(sourcePage, before)
|
||||
if err != nil || snapshot == nil {
|
||||
return err
|
||||
}
|
||||
if err := e.putRichTextSnapshot(page, after, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
for i, id := range ids {
|
||||
object, err := source.Object(sourcePage, id)
|
||||
before, err := source.Object(sourcePage, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object.Type != "CompositeObject" {
|
||||
continue
|
||||
}
|
||||
entry, err := source.richTextEntry(sourcePage, id)
|
||||
if errors.Is(err, ErrNotRichText) {
|
||||
continue
|
||||
}
|
||||
after, err := e.Object(page, copied[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := source.RichText(sourcePage, id); err != nil {
|
||||
if err := copyObject(before, after); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.putRichTextMetadata(page, copied[i], entry.metadata, -1); err != nil {
|
||||
}
|
||||
for i, id := range annotations {
|
||||
before, err := source.Annotation(sourcePage, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
after, err := e.Annotation(page, copiedNotes[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := copyObject(GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: before.Appearance.Objects}}, GraphicObject{Type: "CompositeGraphicUnit", CompositeGraphicUnit: CompositeGraphicUnit{Objects: after.Appearance.Objects}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -815,25 +879,18 @@ func (e *Editor) richTextColors(object *GraphicObject) error {
|
||||
|
||||
// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象
|
||||
// 入参: objects 待复制快照
|
||||
// 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断
|
||||
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) {
|
||||
result := make(map[int]richTextMetadata)
|
||||
// 返回: map[int]*richTextSnapshot 需要复制的语义, error 损坏诊断
|
||||
func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]*richTextSnapshot, error) {
|
||||
result := make(map[int]*richTextSnapshot)
|
||||
for i, object := range objects {
|
||||
if object.Type != "CompositeObject" {
|
||||
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||
continue
|
||||
}
|
||||
if object.origin != nil && e.snapshotOrigin(object) == nil {
|
||||
continue
|
||||
}
|
||||
if object.richText != nil {
|
||||
unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if richTextDigest(unit.object.CompositeGraphicUnit.Objects) != object.richText.Digest {
|
||||
return nil, fmt.Errorf("rich text snapshot resource has changed")
|
||||
}
|
||||
result[i] = cloneEditorData(*object.richText)
|
||||
result[i] = object.richText
|
||||
continue
|
||||
}
|
||||
index := e.richTextMetadataIndex()
|
||||
@@ -843,45 +900,125 @@ func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTe
|
||||
if len(index.entries) == 0 {
|
||||
continue
|
||||
}
|
||||
id := editorObjectID(object)
|
||||
for page := range e.pages {
|
||||
entry, err := e.richTextEntry(page, id)
|
||||
if errors.Is(err, ErrNotRichText) {
|
||||
continue
|
||||
}
|
||||
snapshot, err := e.captureRichTextSnapshot(page, object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := e.Object(page, id)
|
||||
if err != nil {
|
||||
continue
|
||||
if snapshot != nil {
|
||||
result[i] = snapshot
|
||||
break
|
||||
}
|
||||
if current.CompositeGraphicUnit.ResourceID != object.CompositeGraphicUnit.ResourceID {
|
||||
continue
|
||||
}
|
||||
if _, err := e.RichText(page, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = entry.metadata
|
||||
break
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在
|
||||
// attachRichTextSnapshot 为对象快照附加嵌套编辑语义,来源删除后仍可跨页粘贴
|
||||
// 入参: page 页面索引, object 对象快照
|
||||
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) {
|
||||
object.richText = nil
|
||||
if object.Type != "CompositeObject" {
|
||||
return
|
||||
// 返回: error 标引或资源错误
|
||||
func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) error {
|
||||
snapshot, err := e.captureRichTextSnapshot(page, *object)
|
||||
object.richText = snapshot
|
||||
return err
|
||||
}
|
||||
|
||||
// captureRichTextSnapshot 捕获组合树内的富文本标引,不复制共享二进制资源
|
||||
// 入参: page 页面索引, object 根对象
|
||||
// 返回: *richTextSnapshot 不可变快照,无标引时为空, error 标引或资源错误
|
||||
func (e *Editor) captureRichTextSnapshot(page int, object GraphicObject) (*richTextSnapshot, error) {
|
||||
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||
return nil, nil
|
||||
}
|
||||
entry, err := e.richTextEntry(page, editorObjectID(*object))
|
||||
if err != nil {
|
||||
return
|
||||
index := e.richTextMetadataIndex()
|
||||
if index.err != nil || len(index.entries) == 0 {
|
||||
return nil, index.err
|
||||
}
|
||||
meta := cloneEditorData(entry.metadata)
|
||||
object.richText = &meta
|
||||
active := make(map[string]bool)
|
||||
var capture func(GraphicObject) (*richTextSnapshot, error)
|
||||
capture = func(object GraphicObject) (*richTextSnapshot, error) {
|
||||
if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
|
||||
return nil, nil
|
||||
}
|
||||
entry, err := e.richTextEntry(page, editorObjectID(object))
|
||||
if err == nil {
|
||||
if _, err := e.richTextObject(page, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta := cloneEditorData(entry.metadata)
|
||||
return &richTextSnapshot{metadata: &meta}, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotRichText) {
|
||||
return nil, err
|
||||
}
|
||||
unit := object.CompositeGraphicUnit
|
||||
if unit.ResourceID != "" {
|
||||
if active[unit.ResourceID] {
|
||||
return nil, fmt.Errorf("cyclic composite resource")
|
||||
}
|
||||
active[unit.ResourceID] = true
|
||||
defer delete(active, unit.ResourceID)
|
||||
definition, err := e.compositeDefinition(unit.ResourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unit = definition.object.CompositeGraphicUnit
|
||||
}
|
||||
var snapshot *richTextSnapshot
|
||||
i := 0
|
||||
for child := range unit.objects() {
|
||||
value, err := capture(child)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value != nil {
|
||||
if snapshot == nil {
|
||||
snapshot = &richTextSnapshot{children: make(map[int]*richTextSnapshot)}
|
||||
}
|
||||
snapshot.children[i] = value
|
||||
}
|
||||
i++
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
return capture(object)
|
||||
}
|
||||
|
||||
// putRichTextSnapshot 恢复复制后的嵌套标引,按目标标准内容更新摘要和字体映射
|
||||
// 入参: page 目标页, object 复制后的对象, snapshot 来源不可变语义
|
||||
// 返回: error 结构或标引错误
|
||||
func (e *Editor) putRichTextSnapshot(page int, object GraphicObject, snapshot *richTextSnapshot) error {
|
||||
if snapshot.metadata != nil {
|
||||
index := -1
|
||||
if entry, err := e.richTextEntry(page, editorObjectID(object)); err == nil {
|
||||
index = entry.index
|
||||
} else if !errors.Is(err, ErrNotRichText) {
|
||||
return err
|
||||
}
|
||||
return e.putCompositeRichTextMetadata(page, object, *snapshot.metadata, index)
|
||||
}
|
||||
unit := object.CompositeGraphicUnit
|
||||
if unit.ResourceID != "" {
|
||||
definition, err := e.compositeDefinition(unit.ResourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unit = definition.object.CompositeGraphicUnit
|
||||
}
|
||||
i, count := 0, 0
|
||||
for child := range unit.objects() {
|
||||
if value := snapshot.children[i]; value != nil {
|
||||
if err := e.putRichTextSnapshot(page, child, value); err != nil {
|
||||
return err
|
||||
}
|
||||
count++
|
||||
}
|
||||
i++
|
||||
}
|
||||
if count != len(snapshot.children) {
|
||||
return fmt.Errorf("copied composite structure differs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查
|
||||
@@ -945,3 +1082,189 @@ func richTextMetadataFontSafe(value string) bool {
|
||||
}
|
||||
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)
|
||||
}
|
||||
+305
-3
@@ -16,9 +16,12 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ObjectSelection 保存正文、注解及组合成员的独立快照,共享当前编辑器的不可变资源
|
||||
@@ -863,12 +866,311 @@ func (e *Editor) ArrangeTargets(page int, paths []ObjectPath, distribute bool, d
|
||||
// 入参: page 页面索引, paths 完整文字路径, style 字体、字号或颜色增量
|
||||
// 返回: error 路径、文字或样式错误
|
||||
func (e *Editor) StyleTargetsText(page int, paths []ObjectPath, style TextStyle) error {
|
||||
return e.editTargets(paths, func(objects, annotations []string) 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")
|
||||
}
|
||||
return e.StyleText(page, objects, style)
|
||||
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 {
|
||||
return e.StyleCompositeText(page, path, indexes, style)
|
||||
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{})
|
||||
}
|
||||
+62
-8
@@ -15,6 +15,9 @@
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
@@ -34,8 +37,8 @@ type MissingGlyphError struct {
|
||||
|
||||
// TextLayout 本地横向段落选项
|
||||
// Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位
|
||||
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留
|
||||
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
||||
// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存
|
||||
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed
|
||||
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
||||
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
||||
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
|
||||
@@ -63,10 +66,50 @@ type TextStyle struct {
|
||||
Reflow bool
|
||||
}
|
||||
|
||||
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染
|
||||
// textLayout 保存可恢复的原文与排版选项,不参与阅读渲染
|
||||
type textLayout struct {
|
||||
value string
|
||||
options TextLayout
|
||||
value string
|
||||
options TextLayout
|
||||
autoWidth bool
|
||||
}
|
||||
|
||||
// textNamespace 标识普通文字编辑语义的命名空间,版本仅随不兼容的语义变更升级
|
||||
const textNamespace = "https://ofdgo.aite.me/ns/text/1"
|
||||
|
||||
// textMetadataAttribute 判断无资源引用的普通文字编辑元数据属性
|
||||
// 入参: node 所属节点, attr 属性
|
||||
// 返回: bool 是否为本库普通文字元数据
|
||||
func textMetadataAttribute(node xml.Name, attr xml.Attr) bool {
|
||||
return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textNamespace && attr.Name.Local == "Text"
|
||||
}
|
||||
|
||||
// textMetadata 保存原文、选项与标准定位摘要
|
||||
type textMetadata struct {
|
||||
Text string `json:"text"`
|
||||
Layout TextLayout `json:"layout"`
|
||||
AutoWidth bool `json:"autoWidth"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// textDigest 计算与字体编号无关的文字定位摘要
|
||||
// 返回: string 摘要
|
||||
func (obj TextObject) textDigest() string {
|
||||
codes := append([]TextCode(nil), obj.TextCode...)
|
||||
for i := range codes {
|
||||
codes[i].Value = string(textCodeRunes(codes[i].Value))
|
||||
}
|
||||
data, _ := json.Marshal(codes)
|
||||
return fmt.Sprintf("%x", sha256.Sum256(data))
|
||||
}
|
||||
|
||||
// textMetadata 编码普通文字的排版语义,与标准定位一起保存
|
||||
// 返回: string 元数据, error 编码错误
|
||||
func (obj TextObject) textMetadata() (string, error) {
|
||||
if obj.layout == nil {
|
||||
return "", nil
|
||||
}
|
||||
data, err := json.Marshal(textMetadata{Text: obj.layout.value, Layout: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.textDigest()})
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// Error 返回缺失字符的Unicode码点
|
||||
@@ -106,7 +149,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error)
|
||||
return e.UpdateObjects(page, updates)
|
||||
}
|
||||
|
||||
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排
|
||||
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排
|
||||
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
||||
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
||||
// 返回: error 错误信息
|
||||
@@ -633,9 +676,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
||||
}
|
||||
|
||||
// alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐
|
||||
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距
|
||||
// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点
|
||||
// 返回: float64 行首X坐标, string 字符间的DeltaX序列
|
||||
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) {
|
||||
func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64, clusters map[int]bool) (float64, string) {
|
||||
end := len(runes)
|
||||
for end > 0 && runes[end-1] == ' ' {
|
||||
end--
|
||||
@@ -665,6 +708,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st
|
||||
}
|
||||
}
|
||||
}
|
||||
if alignment == "distributed" && extra > 0 {
|
||||
var seg segmenter.Segmenter
|
||||
seg.Init(runes[:end])
|
||||
for it := seg.GraphemeIterator(); it.Next(); {
|
||||
cluster := it.Grapheme()
|
||||
at := cluster.Offset + len(cluster.Text)
|
||||
if at < end && (clusters == nil || clusters[at]) {
|
||||
gaps[at-1] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
deltas := make([]string, max(0, len(runes)-1))
|
||||
for i := range deltas {
|
||||
advance := advances[i]
|
||||
|
||||
@@ -817,7 +817,7 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
||||
}
|
||||
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
||||
}
|
||||
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||
if err := e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||
for i, node := range nodes {
|
||||
if selection.Indexes[i] != first.Index {
|
||||
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
||||
@@ -838,5 +838,18 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
||||
node.setState(editorCompositeState{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
index := first.Index
|
||||
for _, part := range preview.analysis.Parts[1:] {
|
||||
if part.Index < first.Index {
|
||||
index--
|
||||
}
|
||||
}
|
||||
member, err := e.CompositeObject(page, *selection.Root, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
|
||||
}
|
||||
+35
-2
@@ -397,6 +397,9 @@ func editorXMLAttributes(node *editorXML, allowed string) bool {
|
||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
||||
continue
|
||||
}
|
||||
if textMetadataAttribute(node.name, attr) {
|
||||
continue
|
||||
}
|
||||
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
||||
return false
|
||||
}
|
||||
@@ -569,6 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
||||
return nil, err
|
||||
}
|
||||
changes := make(map[string]string)
|
||||
textValue := func(n *editorXML) string {
|
||||
for _, attr := range n.attrs {
|
||||
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
|
||||
return attr.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
textChanged := textValue(oldNode) != textValue(newNode)
|
||||
if legacyClip {
|
||||
changes["TransFlag"] = ""
|
||||
}
|
||||
@@ -578,7 +590,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
||||
}
|
||||
}
|
||||
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
|
||||
if len(changes) == 0 && !contentChanged && namespace == node.name.Space {
|
||||
if len(changes) == 0 && !textChanged && !contentChanged && namespace == node.name.Space {
|
||||
return bytes.Clone(data[node.start:node.end]), nil
|
||||
}
|
||||
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
||||
@@ -587,8 +599,29 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
||||
return nil, err
|
||||
}
|
||||
start := token.(xml.StartElement)
|
||||
if textChanged {
|
||||
value, found := textValue(newNode), false
|
||||
attrs := start.Attr[:0]
|
||||
for i, attr := range start.Attr {
|
||||
resolved := node.attrs[i]
|
||||
if resolved.Name.Space == textNamespace && resolved.Name.Local == "Text" {
|
||||
found = true
|
||||
if value != "" {
|
||||
attr.Value = value
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
start.Attr = attrs
|
||||
if !found && value != "" {
|
||||
prefix := editorXMLNamespacePrefix(node, "ofdgo")
|
||||
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Text"}, Value: value})
|
||||
}
|
||||
}
|
||||
if legacyClip || namespace != node.name.Space {
|
||||
start.Name.Space = editorXMLNamespacePrefix(node)
|
||||
start.Name.Space = editorXMLNamespacePrefix(node, "ofd")
|
||||
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace})
|
||||
}
|
||||
var result bytes.Buffer
|
||||
|
||||
@@ -17,14 +17,112 @@ package ofdgo
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||
)
|
||||
|
||||
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
|
||||
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
|
||||
type FontFace = basefont.Face
|
||||
|
||||
// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口
|
||||
// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源
|
||||
// 返回: [][]int 按首次出现顺序分组的候选索引
|
||||
func FontFamilyIndexes(faces []FontFace, sources []string) [][]int {
|
||||
groups := make([][]int, 0, len(faces))
|
||||
keys := make([]string, len(faces))
|
||||
styles := make(map[string]map[[2]bool]bool)
|
||||
ambiguous := make(map[string]bool)
|
||||
for index, face := range faces {
|
||||
key := face.FamilyKey
|
||||
if len(sources) > index {
|
||||
key += "\x00" + sources[index]
|
||||
}
|
||||
keys[index] = key
|
||||
if styles[key] == nil {
|
||||
styles[key] = make(map[[2]bool]bool)
|
||||
}
|
||||
style := [2]bool{face.Bold, face.Italic}
|
||||
ambiguous[key] = ambiguous[key] || styles[key][style]
|
||||
styles[key][style] = true
|
||||
}
|
||||
indexes := make(map[string]int)
|
||||
for index, face := range faces {
|
||||
key := keys[index]
|
||||
if face.FamilyKey == "" || ambiguous[key] {
|
||||
groups = append(groups, []int{index})
|
||||
continue
|
||||
}
|
||||
group, exists := indexes[key]
|
||||
if !exists {
|
||||
group, indexes[key] = len(groups), len(groups)
|
||||
groups = append(groups, nil)
|
||||
}
|
||||
groups[group] = append(groups[group], index)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式
|
||||
// 入参: faces 同来源同族候选, bold 粗体, italic 斜体
|
||||
// 返回: int 候选索引,无可用款式时为-1
|
||||
func FontStyleIndex(faces []FontFace, bold, italic bool) int {
|
||||
selected, best := -1, -1
|
||||
for index, face := range faces {
|
||||
if face.Bold && !bold || face.Italic && !italic {
|
||||
continue
|
||||
}
|
||||
score := 0
|
||||
if face.Bold == bold {
|
||||
score++
|
||||
}
|
||||
if face.Italic == italic {
|
||||
score++
|
||||
}
|
||||
if score > best {
|
||||
selected, best = index, score
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序
|
||||
// 入参: faces 候选字体, families 按优先级排列的字体族别名组
|
||||
// 返回: []int 候选字体的零起始索引,不修改输入列表
|
||||
func FontPreferenceOrder(faces []FontFace, families [][]string) []int {
|
||||
priorities := make(map[string]int)
|
||||
for priority, names := range families {
|
||||
for _, name := range names {
|
||||
key := FontNormalizeName(name)
|
||||
if _, exists := priorities[key]; key != "" && !exists {
|
||||
priorities[key] = priority
|
||||
}
|
||||
}
|
||||
}
|
||||
order, ranks := make([]int, len(faces)), make([]int, len(faces))
|
||||
for i, face := range faces {
|
||||
order[i], ranks[i] = i, len(families)*2
|
||||
names := append([]string{face.Family, face.FullName, face.PostScriptName}, face.Names...)
|
||||
for _, name := range names {
|
||||
if priority, exists := priorities[FontNormalizeName(name)]; exists && priority*2 < ranks[i] {
|
||||
ranks[i] = priority * 2
|
||||
}
|
||||
}
|
||||
if ranks[i] < len(families)*2 {
|
||||
switch FontNormalizeName(face.Style) {
|
||||
case "", "regular", "normal", "roman", "常规", "正常", "标准":
|
||||
default:
|
||||
ranks[i]++
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(order, func(i, j int) bool { return ranks[order[i]] < ranks[order[j]] })
|
||||
return order
|
||||
}
|
||||
|
||||
// FixFontDataAggressive 激进修复字体数据
|
||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||
|
||||
+41
-1
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
|
||||
return r.fontData(id, false)
|
||||
}
|
||||
|
||||
// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体
|
||||
// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识
|
||||
// 返回: *FontFace 独立字体信息, error 资源或解析错误
|
||||
func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) {
|
||||
if r.fontDefinition(id) == nil {
|
||||
if _, err := r.Fonts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
font := r.fontDefinition(id)
|
||||
if font == nil || font.FontFile == "" {
|
||||
return nil, fmt.Errorf("embedded font %q not found", id)
|
||||
}
|
||||
name := r.ResPath(font.FontFile)
|
||||
key := fmt.Sprintf("%s\x00%d:%d", name, platform, language)
|
||||
faces, ok := r.fontFaces[key]
|
||||
if !ok {
|
||||
data, err := r.readFileView(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values, err := (FontFile{Data: data}).LocalizedFaces(platform, language)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
faces = make([]*FontFace, len(values))
|
||||
for index := range values {
|
||||
faces[index] = &values[index]
|
||||
}
|
||||
r.fontFaces[key] = faces
|
||||
}
|
||||
names := make([][]string, len(faces))
|
||||
for index, face := range faces {
|
||||
names[index] = face.Names
|
||||
}
|
||||
face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)]
|
||||
face.Names = slices.Clone(face.Names)
|
||||
return &face, nil
|
||||
}
|
||||
|
||||
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
||||
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
||||
// 返回: []byte 字体数据, error 资源或集合错误
|
||||
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
|
||||
faces = make([]*FontFace, count)
|
||||
for index := range faces {
|
||||
if tables, err := basefont.FileTables(data, index); err == nil {
|
||||
info := basefont.FaceInfo(tables["name"], index)
|
||||
info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff)
|
||||
faces[index] = &info
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ type GraphicObject struct {
|
||||
CompositeGraphicUnit CompositeGraphicUnit
|
||||
origin *editorObjectOrigin
|
||||
state editorCompositeState
|
||||
richText *richTextMetadata
|
||||
richText *richTextSnapshot
|
||||
}
|
||||
|
||||
// Clips 相对于对象外接矩形的裁剪集合
|
||||
|
||||
+1
-1
@@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string
|
||||
return false, false, nil, nil
|
||||
}
|
||||
for _, attr := range token.Attr {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace {
|
||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(token.Name, attr) {
|
||||
return false, false, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
+50
-5
@@ -31,6 +31,7 @@ import (
|
||||
|
||||
// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号
|
||||
type richLayout struct {
|
||||
width float64
|
||||
objects []GraphicObject
|
||||
bounds Box
|
||||
anchors []int
|
||||
@@ -91,8 +92,7 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
|
||||
return richLayout{}, err
|
||||
}
|
||||
options := value.Layout
|
||||
available := width - options.LeftIndent - options.RightIndent
|
||||
if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 {
|
||||
if !finite(width) || width <= 0 {
|
||||
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
|
||||
}
|
||||
geometry, err := e.Geometry()
|
||||
@@ -124,16 +124,52 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
|
||||
}
|
||||
chars := []rune(value.Text())
|
||||
var face *typefont.Face
|
||||
if value.AutoWidth && !options.Wrap {
|
||||
width = 1
|
||||
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
|
||||
end := start
|
||||
for end < len(chars) && chars[end] != '\n' {
|
||||
end++
|
||||
}
|
||||
paragraph := chars[start:end]
|
||||
local := richParagraphOptions(paragraph, value.ParagraphLayout(paragraphIndex))
|
||||
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
|
||||
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
|
||||
if err != nil {
|
||||
return richLayout{}, err
|
||||
}
|
||||
}
|
||||
_, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face)
|
||||
if err != nil {
|
||||
return richLayout{}, err
|
||||
}
|
||||
length := 0.0
|
||||
for _, advance := range advances {
|
||||
length += advance
|
||||
}
|
||||
width = max(width, local.LeftIndent+local.RightIndent+max(0, local.FirstLineIndent)+1, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length)
|
||||
if end == len(chars) {
|
||||
break
|
||||
}
|
||||
start = end + 1
|
||||
}
|
||||
}
|
||||
result.width = width
|
||||
var underlays []GraphicObject
|
||||
var shadows []GraphicObject
|
||||
var y float64
|
||||
for start := 0; start <= len(chars); {
|
||||
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
|
||||
end := start
|
||||
for end < len(chars) && chars[end] != '\n' {
|
||||
end++
|
||||
}
|
||||
paragraph := chars[start:end]
|
||||
local := richParagraphOptions(paragraph, options)
|
||||
local := value.ParagraphLayout(paragraphIndex)
|
||||
available := width - local.LeftIndent - local.RightIndent
|
||||
if !finite(available) || available <= 0 || !finite(available-local.FirstLineIndent) || available-local.FirstLineIndent <= 0 {
|
||||
return richLayout{}, fmt.Errorf("invalid rich text width or indents")
|
||||
}
|
||||
local = richParagraphOptions(paragraph, local)
|
||||
if local.Shaping.Bidi && face == nil && len(paragraph) > 0 {
|
||||
face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write()))
|
||||
if err != nil {
|
||||
@@ -194,7 +230,16 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro
|
||||
ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height)
|
||||
}
|
||||
height = max(height, ascent+descent)
|
||||
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing)
|
||||
var clusters map[int]bool
|
||||
if local.Align == "distributed" {
|
||||
clusters = make(map[int]bool)
|
||||
for _, span := range spans {
|
||||
for _, glyph := range span.glyphs {
|
||||
clusters[span.start+glyph.Cluster] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing, clusters)
|
||||
copy(steps, parseFloats(delta))
|
||||
if local.Shaping.Direction == "rtl" {
|
||||
for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- {
|
||||
|
||||
+189
-6
@@ -17,6 +17,8 @@ package ofdgo
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -27,9 +29,43 @@ import (
|
||||
var ErrNotRichText = errors.New("object is not rich text")
|
||||
|
||||
// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节
|
||||
// Paragraphs为空时所有段落沿用Layout,否则数量必须与LF分隔的段落一致
|
||||
// AutoWidth在不折行时按各段自然宽度确定文本框,指定宽度调整后关闭
|
||||
type RichText struct {
|
||||
Runs []RichTextRun `json:"runs"`
|
||||
Layout TextLayout `json:"layout"`
|
||||
AutoWidth bool `json:"autoWidth,omitempty"`
|
||||
Runs []RichTextRun `json:"runs"`
|
||||
Layout TextLayout `json:"layout"`
|
||||
Paragraphs []RichTextParagraph `json:"paragraphs,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextParagraph 保存显式段落选项,空值沿用Layout,按LF分隔的段落顺序排列
|
||||
type RichTextParagraph struct {
|
||||
Align string `json:"align"`
|
||||
LineHeight *float64 `json:"lineHeight,omitempty"`
|
||||
LetterSpacing *float64 `json:"letterSpacing,omitempty"`
|
||||
LeftIndent *float64 `json:"leftIndent,omitempty"`
|
||||
RightIndent *float64 `json:"rightIndent,omitempty"`
|
||||
FirstLineIndent *float64 `json:"firstLineIndent,omitempty"`
|
||||
}
|
||||
|
||||
// ParagraphLayout 返回指定段落覆盖后的排版选项
|
||||
// 入参: index 段落序号
|
||||
// 返回: TextLayout 有效段落选项
|
||||
func (v RichText) ParagraphLayout(index int) TextLayout {
|
||||
options := v.Layout
|
||||
if index < 0 || index >= len(v.Paragraphs) {
|
||||
return options
|
||||
}
|
||||
p := v.Paragraphs[index]
|
||||
if p.Align != "" {
|
||||
options.Align = p.Align
|
||||
}
|
||||
for _, pair := range []struct{ source, target *float64 }{{p.LineHeight, &options.LineHeight}, {p.LetterSpacing, &options.LetterSpacing}, {p.LeftIndent, &options.LeftIndent}, {p.RightIndent, &options.RightIndent}, {p.FirstLineIndent, &options.FirstLineIndent}} {
|
||||
if pair.source != nil {
|
||||
*pair.target = *pair.source
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米
|
||||
@@ -64,6 +100,59 @@ type TextFormatPatch struct {
|
||||
Background *string `json:"background,omitempty"`
|
||||
}
|
||||
|
||||
// RichTextFonts 提取富文本实际引用的独立字体数据,供跨文档保存和复制使用
|
||||
// 入参: value 富文本内容
|
||||
// 返回: map[string][]byte 字体标识及数据, error 字体读取错误
|
||||
func (e *Editor) RichTextFonts(value RichText) (map[string][]byte, error) {
|
||||
fonts := make(map[string][]byte)
|
||||
for _, run := range value.Runs {
|
||||
if run.Text == "" || fonts[run.Format.Font] != nil {
|
||||
continue
|
||||
}
|
||||
data, err := e.FontData(run.Format.Font)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fonts[run.Format.Font] = data
|
||||
}
|
||||
return fonts, nil
|
||||
}
|
||||
|
||||
// ImportRichText 注册富文本引用的外来字体并返回独立内容,失败时不保留新增资源
|
||||
// 未提供数据的字体标识保留为当前文档引用,调用方可在外层事务中同时创建或更新对象
|
||||
// 入参: value 富文本内容, fonts 外来字体标识及数据
|
||||
// 返回: RichText 已映射字体标识的内容, error 字体注册错误
|
||||
func (e *Editor) ImportRichText(value RichText, fonts map[string][]byte) (RichText, error) {
|
||||
value = cloneEditorData(value)
|
||||
used := make(map[string]bool)
|
||||
for _, run := range value.Runs {
|
||||
used[run.Format.Font] = true
|
||||
}
|
||||
err := e.Transaction(func(editor *Editor) error {
|
||||
registered := make(map[string]string)
|
||||
for _, key := range slices.Sorted(maps.Keys(fonts)) {
|
||||
if !used[key] {
|
||||
continue
|
||||
}
|
||||
id, err := editor.AddFont(FontFile{Data: fonts[key]}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
registered[key] = id
|
||||
}
|
||||
for i := range value.Runs {
|
||||
if id := registered[value.Runs[i].Format.Font]; id != "" {
|
||||
value.Runs[i].Format.Font = id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Text 返回包含显式换行的原文,不包含排版产生的软换行
|
||||
// 返回: string 完整原文
|
||||
func (v RichText) Text() string {
|
||||
@@ -87,6 +176,14 @@ func (v RichText) Validate() error {
|
||||
if err := validateRichLayout(v.Layout); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(v.Paragraphs) != 0 && len(v.Paragraphs) != strings.Count(v.Text(), "\n")+1 {
|
||||
return fmt.Errorf("rich text paragraph count does not match text")
|
||||
}
|
||||
for index := range v.Paragraphs {
|
||||
if err := validateRichLayout(v.ParagraphLayout(index)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
bounds := richTextBoundaries(v.Text())
|
||||
position := 0
|
||||
for _, run := range v.Runs {
|
||||
@@ -134,7 +231,7 @@ func validateRichLayout(v TextLayout) error {
|
||||
return fmt.Errorf("invalid rich text layout dimensions")
|
||||
}
|
||||
switch v.Align {
|
||||
case "", "left", "center", "right", "justify":
|
||||
case "", "left", "center", "right", "justify", "distributed":
|
||||
default:
|
||||
return fmt.Errorf("invalid rich text alignment")
|
||||
}
|
||||
@@ -177,7 +274,13 @@ func (v RichText) Slice(start, end int) (RichText, error) {
|
||||
if err := v.checkRange(start, end); err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
result := RichText{Layout: v.Layout, Runs: v.sliceRuns(start, end)}
|
||||
result := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(start, end)}
|
||||
if len(v.Paragraphs) > 0 {
|
||||
chars := []rune(v.Text())
|
||||
first := strings.Count(string(chars[:start]), "\n")
|
||||
count := strings.Count(string(chars[start:end]), "\n") + 1
|
||||
result.Paragraphs = cloneEditorData(v.Paragraphs[first : first+count])
|
||||
}
|
||||
if len(result.Runs) == 0 {
|
||||
position := 0
|
||||
format := v.Runs[len(v.Runs)-1].Format
|
||||
@@ -220,13 +323,28 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error {
|
||||
if err := v.checkRange(start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
next := RichText{Layout: v.Layout, Runs: v.sliceRuns(0, start)}
|
||||
next := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(0, start)}
|
||||
next.Runs = append(next.Runs, runs...)
|
||||
next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...)
|
||||
if len(next.Runs) == 0 {
|
||||
caret, _ := v.Slice(start, start)
|
||||
next.Runs = caret.Runs
|
||||
}
|
||||
if len(v.Paragraphs) > 0 {
|
||||
chars := []rune(v.Text())
|
||||
first := strings.Count(string(chars[:start]), "\n")
|
||||
last := first + strings.Count(string(chars[start:end]), "\n")
|
||||
next.Paragraphs = slices.Clone(v.Paragraphs[:first])
|
||||
count := 1
|
||||
for _, run := range runs {
|
||||
count += strings.Count(run.Text, "\n")
|
||||
}
|
||||
for range count {
|
||||
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[first])
|
||||
}
|
||||
next.Paragraphs = append(next.Paragraphs, v.Paragraphs[last+1:]...)
|
||||
next.Paragraphs = cloneEditorData(next.Paragraphs)
|
||||
}
|
||||
next = normalizeRichText(next)
|
||||
if err := next.Validate(); err != nil {
|
||||
return err
|
||||
@@ -255,7 +373,72 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error {
|
||||
if start == end {
|
||||
return nil
|
||||
}
|
||||
return v.Replace(start, end, part.Runs)
|
||||
paragraphs := v.Paragraphs
|
||||
if err := v.Replace(start, end, part.Runs); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Paragraphs = paragraphs
|
||||
return nil
|
||||
}
|
||||
|
||||
// AlignParagraphs 修改光标所在或半开选区涉及的段落,选区末端不包含下一段首
|
||||
// 入参: start 起点, end 终点, align 对齐方式
|
||||
// 返回: error 无效范围或对齐方式
|
||||
func (v *RichText) AlignParagraphs(start, end int, align string) error {
|
||||
if err := v.FormatParagraphs(start, end, RichTextParagraph{Align: align}); err != nil {
|
||||
return err
|
||||
}
|
||||
if align == "" {
|
||||
chars := []rune(v.Text())
|
||||
first := strings.Count(string(chars[:start]), "\n")
|
||||
last := strings.Count(string(chars[:max(start, end-1)]), "\n")
|
||||
for i := first; i <= last; i++ {
|
||||
v.Paragraphs[i].Align = ""
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatParagraphs 修改当前或选中段落的显式选项,未提供的字段保持原值
|
||||
// 入参: start 起点, end 终点, patch 段落增量
|
||||
// 返回: error 范围或排版错误
|
||||
func (v *RichText) FormatParagraphs(start, end int, patch RichTextParagraph) error {
|
||||
if v == nil {
|
||||
return fmt.Errorf("nil rich text")
|
||||
}
|
||||
if err := v.checkRange(start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRichLayout((RichText{Paragraphs: []RichTextParagraph{patch}}).ParagraphLayout(0)); err != nil {
|
||||
return err
|
||||
}
|
||||
chars := []rune(v.Text())
|
||||
first := strings.Count(string(chars[:start]), "\n")
|
||||
last := first
|
||||
if end > start {
|
||||
last = strings.Count(string(chars[:end-1]), "\n")
|
||||
}
|
||||
paragraphs := slices.Clone(v.Paragraphs)
|
||||
if len(paragraphs) == 0 {
|
||||
paragraphs = make([]RichTextParagraph, strings.Count(v.Text(), "\n")+1)
|
||||
}
|
||||
for i := first; i <= last; i++ {
|
||||
p := ¶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 应用显式给出的格式字段
|
||||
|
||||
@@ -24,8 +24,15 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// PageTextCache 按内存预算保留最近访问的页面文字索引
|
||||
// 缓存后不得修改PageText,页面或字体变化时须删除对应条目
|
||||
type PageTextCache struct {
|
||||
cache *renderCache[int, *PageText]
|
||||
}
|
||||
|
||||
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
|
||||
type textPageTokens struct {
|
||||
*xml.Decoder
|
||||
@@ -76,6 +83,83 @@ type TextMatchPart struct {
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
// NewPageTextCache 创建文字索引缓存,预算包含字符区域和条目开销
|
||||
// 入参: bytes 内存预算,非正数禁用缓存
|
||||
// 返回: *PageTextCache 独立缓存
|
||||
func NewPageTextCache(bytes int) *PageTextCache {
|
||||
return &PageTextCache{cache: resourceCache[int, *PageText](nil, max(0, bytes))}
|
||||
}
|
||||
|
||||
// MemorySize 估算文字索引占用,包含切片容量和字符串内容
|
||||
// 返回: int64 估算字节数,共享字符串按各次引用计费
|
||||
func (p *PageText) MemorySize() int64 {
|
||||
size := int64(unsafe.Sizeof(*p)) + int64(cap(p.Runs))*int64(unsafe.Sizeof(TextRun{}))
|
||||
for _, run := range p.Runs {
|
||||
size += int64(len(run.ID)) + int64(len(run.Text))
|
||||
size += int64(cap(run.Boxes))*int64(unsafe.Sizeof(Box{})) + int64(cap(run.Spans))*int64(unsafe.Sizeof(TextSpan{}))
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// Get 获取文字索引并更新最近访问顺序
|
||||
// 入参: index 页面索引
|
||||
// 返回: *PageText 文字索引,未命中时为nil
|
||||
func (c *PageTextCache) Get(index int) *PageText {
|
||||
text, _ := c.cache.get(index)
|
||||
return text
|
||||
}
|
||||
|
||||
// Put 缓存完整文字索引,超出预算的单页不驱逐其他页面
|
||||
// 入参: index 页面索引, text 文字索引,nil删除已有条目
|
||||
func (c *PageTextCache) Put(index int, text *PageText) {
|
||||
if text == nil {
|
||||
c.Delete(index)
|
||||
return
|
||||
}
|
||||
size := text.MemorySize() + 128
|
||||
if size > int64(c.cache.limit) {
|
||||
c.Delete(index)
|
||||
return
|
||||
}
|
||||
c.cache.put(index, text, int(size))
|
||||
}
|
||||
|
||||
// Delete 删除页面索引并释放预算
|
||||
// 入参: index 页面索引
|
||||
func (c *PageTextCache) Delete(index int) {
|
||||
c.cache.mu.Lock()
|
||||
defer c.cache.mu.Unlock()
|
||||
if entry := c.cache.entries[index]; entry != nil {
|
||||
c.cache.remove(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空缓存和计费状态
|
||||
func (c *PageTextCache) Clear() {
|
||||
c.cache.clear()
|
||||
}
|
||||
|
||||
// Len 返回缓存页面数
|
||||
// 返回: int 已缓存页面数
|
||||
func (c *PageTextCache) Len() int {
|
||||
c.cache.mu.Lock()
|
||||
defer c.cache.mu.Unlock()
|
||||
return len(c.cache.entries)
|
||||
}
|
||||
|
||||
// Range 按访问时间从旧到新遍历,遍历期间不得访问当前缓存
|
||||
// 入参: visit 访问函数,返回false停止遍历
|
||||
func (c *PageTextCache) Range(visit func(int, *PageText) bool) {
|
||||
c.cache.mu.Lock()
|
||||
defer c.cache.mu.Unlock()
|
||||
for entry := c.cache.order.Back(); entry != nil; entry = entry.Prev() {
|
||||
value := entry.Value.(renderCacheEntry[int, *PageText])
|
||||
if !visit(value.key, value.value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Token 读取文字页面所需的XML节点
|
||||
// 返回: xml.Token XML节点, error 错误信息
|
||||
func (d *textPageTokens) Token() (xml.Token, error) {
|
||||
|
||||
@@ -21,8 +21,10 @@ import (
|
||||
"crypto"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -40,6 +42,261 @@ const (
|
||||
// SignatureWriteMode 签署方式
|
||||
type SignatureWriteMode uint8
|
||||
|
||||
// ErrSignatureDependency 表示移除操作会改变保留签名或其他版本使用的入口和保护文件
|
||||
var ErrSignatureDependency = errors.New("signature dependency conflict")
|
||||
|
||||
// RemoveSignature 移除当前文档版本的指定签名及外观,支持撤销重做
|
||||
// 仅清理无其他引用的签名文件,依赖冲突时不修改文档
|
||||
// 入参: id 签名标识
|
||||
// 返回: error 标识、依赖或读取错误
|
||||
func (e *Editor) RemoveSignature(id string) error {
|
||||
return e.RemoveSignatureContext(context.Background(), id)
|
||||
}
|
||||
|
||||
// RemoveSignatureContext 移除指定签名,取消或失败时不改变文档和历史
|
||||
// 入参: ctx 取消上下文, id 签名标识
|
||||
// 返回: error 标识、依赖、读取或取消错误
|
||||
func (e *Editor) RemoveSignatureContext(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("empty signature ID")
|
||||
}
|
||||
return e.removeSignatures(ctx, id)
|
||||
}
|
||||
|
||||
// ClearSignatures 清空当前文档版本的签名及外观,保留其他文档和版本的引用
|
||||
// 无签名时不产生历史记录,依赖冲突时不修改文档
|
||||
// 返回: error 依赖或读取错误
|
||||
func (e *Editor) ClearSignatures() error {
|
||||
return e.ClearSignaturesContext(context.Background())
|
||||
}
|
||||
|
||||
// ClearSignaturesContext 清空当前签名,取消或失败时不改变文档和历史
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: error 依赖、读取或取消错误
|
||||
func (e *Editor) ClearSignaturesContext(ctx context.Context) error {
|
||||
return e.removeSignatures(ctx, "")
|
||||
}
|
||||
|
||||
// removeSignatures 在独立快照中移除签名入口,校验保留依赖后提交历史
|
||||
// 入参: ctx 取消上下文, id 签名标识,空值清空
|
||||
// 返回: error 标识、依赖、结构或取消错误
|
||||
func (e *Editor) removeSignatures(ctx context.Context, id string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
reader, err := e.readerContext(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc.Signatures == "" {
|
||||
if id != "" {
|
||||
return fmt.Errorf("signature not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
name := reader.ResPath(doc.Signatures)
|
||||
data, err := reader.readFileViewContext(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removed := make(map[string]bool)
|
||||
candidates := make(map[string]bool)
|
||||
var patches []editorXMLPatch
|
||||
remaining := 0
|
||||
for _, node := range root.children {
|
||||
if !node.matchesOFD("Signature") {
|
||||
continue
|
||||
}
|
||||
if id != "" && node.attr("ID") != id {
|
||||
remaining++
|
||||
continue
|
||||
}
|
||||
location := signatureRefPath(name, node.attr("BaseLoc"))
|
||||
files, _, err := reader.signatureRemovalFiles(ctx, location)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maps.Copy(candidates, files)
|
||||
removed[node.attr("ID")] = true
|
||||
patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
|
||||
}
|
||||
if id != "" && !removed[id] {
|
||||
return fmt.Errorf("signature not found: %s", id)
|
||||
}
|
||||
protected, dependencies, err := reader.signatureRemovalProtected(ctx, name, removed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := make(map[string][]byte)
|
||||
if reader.versionInfo != nil {
|
||||
if remaining != 0 {
|
||||
return fmt.Errorf("%w: version shares signature list %s", ErrSignatureDependency, name)
|
||||
}
|
||||
candidates[name] = true
|
||||
used, err := reader.versionFileDependencies(ctx, reader.documentRoot, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removedFiles := maps.Clone(candidates)
|
||||
for file := range used {
|
||||
delete(removedFiles, file)
|
||||
}
|
||||
shared, err := reader.versionSharedFiles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reader.versionManifest(ctx, parts, nil, used, shared, removedFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if dependencies[name] {
|
||||
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, name)
|
||||
}
|
||||
for file := range candidates {
|
||||
if dependencies[file] {
|
||||
return fmt.Errorf("%w: retained signature protects %s", ErrSignatureDependency, file)
|
||||
}
|
||||
}
|
||||
versions, err := reader.DocumentVersionsContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, version := range versions {
|
||||
for _, file := range version.Files {
|
||||
if file.Location == name {
|
||||
return fmt.Errorf("%w: version %s uses %s", ErrSignatureDependency, version.ID, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
entry, err := reader.readFileViewContext(ctx, "OFD.xml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ofd, err := parseEditorXMLContext(ctx, entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node := ofd.childAt("DocBody", reader.documentIndex).child("Signatures")
|
||||
if remaining == 0 {
|
||||
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{{start: node.start, end: node.end}})
|
||||
candidates[name] = true
|
||||
} else {
|
||||
target := name
|
||||
shared := false
|
||||
for index, body := range reader.OFD.DocBody {
|
||||
shared = shared || index != reader.documentIndex && body.Signatures != "" && signatureRefPath("OFD.xml", body.Signatures) == name
|
||||
}
|
||||
if shared {
|
||||
target = packageAvailableName(reader, parts, name)
|
||||
parts["OFD.xml"] = editorPatchXML(entry, []editorXMLPatch{editorXMLContent(entry, node, editorFontXMLText("/"+target))})
|
||||
}
|
||||
parts[target] = editorPatchXML(data, patches)
|
||||
}
|
||||
}
|
||||
for file, updated := range parts {
|
||||
if !protected[file] {
|
||||
continue
|
||||
}
|
||||
same, err := packageDataEqual(ctx, reader, file, updated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !same {
|
||||
return fmt.Errorf("%w: protected file %s", ErrSignatureDependency, file)
|
||||
}
|
||||
}
|
||||
for file, data := range reader.files {
|
||||
if _, changed := parts[file]; !changed {
|
||||
parts[file] = data
|
||||
}
|
||||
}
|
||||
reader.files = parts
|
||||
for file := range protected {
|
||||
delete(candidates, file)
|
||||
}
|
||||
if err := reader.retireVersionFiles(ctx, candidates); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.commitResourceSnapshot(ctx, reader)
|
||||
}
|
||||
|
||||
// signatureRemovalFiles 区分签名自有文件和摘要保护内容,正文不作为删除候选
|
||||
// 入参: ctx 取消上下文, name 签名描述路径
|
||||
// 返回: map[string]bool 自有文件, map[string]bool 保护文件, error 读取或解析错误
|
||||
func (r *Reader) signatureRemovalFiles(ctx context.Context, name string) (map[string]bool, map[string]bool, error) {
|
||||
data, err := r.readFileViewContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var signature SignatureFile
|
||||
if err := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), ctx: ctx}).Decode(&signature); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
owned := map[string]bool{name: true}
|
||||
for _, location := range []string{signature.SignedValue, signature.SignedInfo.Seal.BaseLoc} {
|
||||
if location != "" {
|
||||
owned[signatureRefPath(name, location)] = true
|
||||
}
|
||||
}
|
||||
protected := make(map[string]bool)
|
||||
for _, reference := range signature.SignedInfo.References.Reference {
|
||||
protected[signatureRefPath(name, reference.FileRef)] = true
|
||||
}
|
||||
return owned, protected, ctx.Err()
|
||||
}
|
||||
|
||||
// signatureRemovalProtected 汇总保留签名的原文及其他文档的签名入口,不要求证书或私钥
|
||||
// 入参: ctx 取消上下文, selected 当前签名列表, removed 待移除标识
|
||||
// 返回: map[string]bool 保留路径, map[string]bool 非共享的摘要保护路径, error 读取、结构或取消错误
|
||||
func (r *Reader) signatureRemovalProtected(ctx context.Context, selected string, removed map[string]bool) (map[string]bool, map[string]bool, error) {
|
||||
protected := make(map[string]bool)
|
||||
dependencies := make(map[string]bool)
|
||||
retained := make(map[string]bool)
|
||||
for index, body := range r.OFD.DocBody {
|
||||
if body.Signatures == "" {
|
||||
continue
|
||||
}
|
||||
name := signatureRefPath("OFD.xml", body.Signatures)
|
||||
current := index == r.documentIndex && r.versionInfo == nil
|
||||
if !current {
|
||||
protected[name] = true
|
||||
}
|
||||
data, err := r.readFileViewContext(ctx, name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, node := range root.children {
|
||||
if !node.matchesOFD("Signature") || current && name == selected && removed[node.attr("ID")] {
|
||||
continue
|
||||
}
|
||||
owned, refs, err := r.signatureRemovalFiles(ctx, signatureRefPath(name, node.attr("BaseLoc")))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
maps.Copy(protected, owned)
|
||||
maps.Copy(retained, owned)
|
||||
maps.Copy(protected, refs)
|
||||
maps.Copy(dependencies, refs)
|
||||
}
|
||||
}
|
||||
for name := range retained {
|
||||
delete(dependencies, name)
|
||||
}
|
||||
return protected, dependencies, ctx.Err()
|
||||
}
|
||||
|
||||
// SignatureWriteOptions 最终包签署选项,不修改Editor或调用方的原件
|
||||
// SM2数字签名使用GB/T35275-2017消息结构;RSA/ECDSA使用裸签名,验签需WithSignatureCert
|
||||
// Seal仅接受已有合法SES v4电子印章,使用SM2/SM3,不声明GM/T0031-2025兼容
|
||||
|
||||
@@ -16,6 +16,7 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"math"
|
||||
"strconv"
|
||||
@@ -430,6 +431,14 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement
|
||||
return err
|
||||
}
|
||||
*obj = TextObject(value)
|
||||
for _, attr := range start.Attr {
|
||||
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
|
||||
var meta textMetadata
|
||||
if json.Unmarshal([]byte(attr.Value), &meta) == nil && validateRichLayout(meta.Layout) == nil && structureXMLText(meta.Text) && meta.Digest == obj.textDigest() {
|
||||
obj.layout = &textLayout{value: meta.Text, options: meta.Layout, autoWidth: meta.AutoWidth}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, attr := range ofdXMLStart(start).Attr {
|
||||
if attr.Name.Local == "LineWidth" {
|
||||
obj.LineWidthSet = obj.LineWidth == 0
|
||||
|
||||
+15
-7
@@ -33,7 +33,7 @@ type configuredTextShaper struct {
|
||||
}
|
||||
|
||||
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
|
||||
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
|
||||
// 不修改文档,通过AddObject或UpdateObject提交;选项随标准文字定位保存,重新打开时恢复
|
||||
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
|
||||
// 返回: error 错误信息
|
||||
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
|
||||
@@ -63,7 +63,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
||||
if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) {
|
||||
return fmt.Errorf("paragraph indents must be finite")
|
||||
}
|
||||
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
|
||||
if !slices.Contains([]string{"", "left", "center", "right", "justify", "distributed"}, options.Align) {
|
||||
return fmt.Errorf("invalid text alignment %q", options.Align)
|
||||
}
|
||||
var width float64
|
||||
@@ -129,7 +129,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
||||
if i == 0 {
|
||||
indent = options.FirstLineIndent
|
||||
}
|
||||
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing)
|
||||
x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing, nil)
|
||||
x += options.LeftIndent + indent
|
||||
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
||||
if !finite(x) || !finite(y) {
|
||||
@@ -143,10 +143,11 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
||||
}
|
||||
obj.TextCode = codes
|
||||
obj.CGTransform = nil
|
||||
obj.layout = nil
|
||||
if options != (TextLayout{}) {
|
||||
obj.layout = &textLayout{value: value, options: options}
|
||||
autoWidth := options == (TextLayout{})
|
||||
if obj.layout != nil && obj.layout.options == options {
|
||||
autoWidth = obj.layout.autoWidth
|
||||
}
|
||||
obj.layout = &textLayout{value: value, options: options, autoWidth: autoWidth}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -220,7 +221,14 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
|
||||
}
|
||||
}
|
||||
chars := runes[line[0]:line[1]]
|
||||
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing)
|
||||
var clusters map[int]bool
|
||||
if options.Align == "distributed" {
|
||||
clusters = make(map[int]bool)
|
||||
for _, glyph := range glyphs {
|
||||
clusters[glyph.Cluster] = true
|
||||
}
|
||||
}
|
||||
x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing, clusters)
|
||||
if options.Shaping.Direction == "rtl" {
|
||||
for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- {
|
||||
x -= lineAdvances[j]
|
||||
|
||||
@@ -1242,6 +1242,15 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
||||
switch object.Type {
|
||||
case "TextObject", "Text":
|
||||
obj := object.TextObject
|
||||
metadata, err := obj.textMetadata()
|
||||
if err != nil {
|
||||
x.err = err
|
||||
return
|
||||
}
|
||||
if metadata != "" {
|
||||
attrs.add("xmlns:ofdgo", textNamespace)
|
||||
attrs.add("ofdgo:Text", metadata)
|
||||
}
|
||||
attrs.add("ID", obj.ID)
|
||||
attrs.add("DrawParam", obj.DrawParam)
|
||||
attrs.add("Boundary", obj.Boundary)
|
||||
|
||||
+4
-1
@@ -93,7 +93,10 @@ func decodeOFDChildren(d *xml.Decoder, start xml.StartElement, decode func(*xml.
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := decode(d, ofdXMLStart(node)); err != nil {
|
||||
if node.Name.Local != "TextObject" && node.Name.Local != "Text" {
|
||||
node = ofdXMLStart(node)
|
||||
}
|
||||
if err := decode(d, node); err != nil {
|
||||
return err
|
||||
}
|
||||
case xml.EndElement:
|
||||
|
||||
Reference in new issue
Block a user