mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
Compare commits
25
Commits
89a7ef04c7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385d7aa531 | ||
|
|
94157b10c3 | ||
|
|
aebcda51a3 | ||
|
|
19084bb40b | ||
|
|
545b697310 | ||
|
|
1a25472b43 | ||
|
|
af80fcc9ba | ||
|
|
838a769c5b | ||
|
|
5b4f2472f7 | ||
|
|
fb51fd3b07 | ||
|
|
80e548f93e | ||
|
|
9c3a04b7eb | ||
|
|
9bb6c9141a | ||
|
|
4320b53d4a | ||
|
|
e5d7f577c7 | ||
|
|
7a23dcc6dd | ||
|
|
12aaada982 | ||
|
|
62631472ea | ||
|
|
9d6bf3e87f | ||
|
|
f8c8259502 | ||
|
|
f4429b126a | ||
|
|
50898934dc | ||
|
|
c2b0339fdf | ||
|
|
e5658de69d | ||
|
|
057bcba868 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# 基础镜像
|
# 基础镜像
|
||||||
FROM alpine:3.24.2
|
FROM alpine:3.24.3
|
||||||
|
|
||||||
# 作者信息
|
# 作者信息
|
||||||
LABEL authors="肖其顿 (XIAO QI DUN)"
|
LABEL authors="肖其顿 (XIAO QI DUN)"
|
||||||
|
|||||||
+483
-192
File diff suppressed because it is too large.
Load diff
+62
-21
@@ -86,7 +86,7 @@ function distributeParagraph(input, paragraph) {
|
|||||||
document.body.append(probe);
|
document.body.append(probe);
|
||||||
try {
|
try {
|
||||||
const lines = [], range = document.createRange(), width = copy.clientWidth;
|
const lines = [], range = document.createRange(), width = copy.clientWidth;
|
||||||
const spacing = parseFloat(getComputedStyle(probe).letterSpacing) || 0;
|
const spacing = parseFloat(getComputedStyle(copy).letterSpacing) || 0;
|
||||||
const indent = parseFloat(getComputedStyle(copy).textIndent) || 0;
|
const indent = parseFloat(getComputedStyle(copy).textIndent) || 0;
|
||||||
for (const span of copy.children) {
|
for (const span of copy.children) {
|
||||||
const node = span.firstChild;
|
const node = span.firstChild;
|
||||||
@@ -236,6 +236,10 @@ export class RichTextSession {
|
|||||||
const runs = sliceRuns(this.model.runs, start, end);
|
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]));
|
return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined]));
|
||||||
}
|
}
|
||||||
|
selectedRuns() {
|
||||||
|
const [start, end] = this.selection();
|
||||||
|
return start === end ? [{ text: "", format: { ...this.typing } }] : sliceRuns(this.model.runs, start, end);
|
||||||
|
}
|
||||||
remember() {
|
remember() {
|
||||||
this.selection();
|
this.selection();
|
||||||
this.entries[this.index].caret = [...this.caret];
|
this.entries[this.index].caret = [...this.caret];
|
||||||
@@ -302,13 +306,14 @@ export class RichTextSession {
|
|||||||
this.remember();
|
this.remember();
|
||||||
const [start, end] = this.selection();
|
const [start, end] = this.selection();
|
||||||
const before = this.model.runs;
|
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];
|
this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end];
|
||||||
if (start !== end) this.model.runs = normalizeRuns([
|
if (start !== end) this.model.runs = normalizeRuns([
|
||||||
...sliceRuns(this.model.runs, 0, start),
|
...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),
|
...sliceRuns(this.model.runs, end, this.text.length),
|
||||||
]);
|
]);
|
||||||
this.typing = { ...this.typing, ...values };
|
this.typing = apply(this.typing);
|
||||||
this.record(before);
|
this.record(before);
|
||||||
this.render();
|
this.render();
|
||||||
this.editor.options.onTextChange();
|
this.editor.options.onTextChange();
|
||||||
@@ -375,7 +380,7 @@ export class RichTextSession {
|
|||||||
const first = this.text.slice(0, start).split("\n").length - 1;
|
const first = this.text.slice(0, start).split("\n").length - 1;
|
||||||
const paragraphs = text.split("\n").map((_, i) => {
|
const paragraphs = text.split("\n").map((_, i) => {
|
||||||
const layout = { ...this.model.layout, ...this.model.paragraphs?.[first + 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] || "left" : layout[key] || 0]));
|
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?.(),
|
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))) };
|
fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) };
|
||||||
@@ -413,7 +418,9 @@ export class RichTextSession {
|
|||||||
}
|
}
|
||||||
setFont(face, data, key) {
|
setFont(face, data, key) {
|
||||||
key ||= `temp-font-${crypto.randomUUID()}`;
|
key ||= `temp-font-${crypto.randomUUID()}`;
|
||||||
|
const previous = this.faces.get(key);
|
||||||
this.faces.set(key, face);
|
this.faces.set(key, face);
|
||||||
|
if (previous && ![...this.faces.values()].includes(previous)) document.fonts.delete(previous);
|
||||||
if (data) this.imports.set(key, data);
|
if (data) this.imports.set(key, data);
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
this.apply({ font: key });
|
this.apply({ font: key });
|
||||||
@@ -754,6 +761,14 @@ export function selectedText(item) {
|
|||||||
const value = formats[0][key];
|
const value = formats[0][key];
|
||||||
text[key] = formats.every(format => format[key] === value) ? value : undefined;
|
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;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,16 +983,17 @@ export class CanvasEditor {
|
|||||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
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.contour) item.contour.svg.setAttribute("viewBox", `${item.x} ${item.y} ${item.width} ${item.height}`);
|
||||||
if (item.textFrame) {
|
if (item.textFrame) this.placeTextHandles(item, textFrameBox(item), item, change.scale);
|
||||||
const [a, b, c, d, e, f] = item.textFrame.matrix;
|
}
|
||||||
const box = textFrameBox(item);
|
|
||||||
for (const handle of item.node.children) {
|
placeTextHandles(item, box, bounds, scale = 1) {
|
||||||
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
const [a, b, c, d, e, f] = item.textFrame.matrix;
|
||||||
const x = box.x + (handle.dataset.corner === "e" ? box.width : 0), y = box.y + box.height / 2;
|
for (const handle of item.node.children) {
|
||||||
handle.style.left = `${(a * x + c * y + e - item.x) * change.scale * PX_PER_MM}px`;
|
if (handle.dataset.corner !== "w" && handle.dataset.corner !== "e") continue;
|
||||||
handle.style.top = `${(b * x + d * y + f - item.y) * change.scale * PX_PER_MM}px`;
|
const x = box.x + (handle.dataset.corner === "e" ? box.width : 0), y = box.y + box.height / 2;
|
||||||
handle.style.cursor = a === 0 ? "var(--resize-vertical, ns-resize)" : "var(--resize-horizontal, ew-resize)";
|
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)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1082,7 @@ export class CanvasEditor {
|
|||||||
const handles = !canEditObject(item, "transform") ? [] : lineShape(item.shape) ? ["start", "end"]
|
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.shape || item.oriented || canEditObject(item, "stretch") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
|
||||||
: item.type === "TextObject" && canEditObject(item, "reflow") && canEditObject(item, "layoutKnown")
|
: item.type === "TextObject" && canEditObject(item, "reflow") && canEditObject(item, "layoutKnown")
|
||||||
? ["w", "e"] : ["nw", "ne", "sw", "se"];
|
? ["nw", "ne", "sw", "se", "w", "e"] : ["nw", "ne", "sw", "se"];
|
||||||
for (const corner of handles) {
|
for (const corner of handles) {
|
||||||
const handle = document.createElement("span");
|
const handle = document.createElement("span");
|
||||||
handle.className = `edit-handle edit-${corner}`;
|
handle.className = `edit-handle edit-${corner}`;
|
||||||
@@ -1106,6 +1122,14 @@ export class CanvasEditor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectAll(index) {
|
||||||
|
if (!this.enabled || this.input || this.crop || this.drag || this.nudge || this.nudgeCommit || this.options.busy()) return;
|
||||||
|
this.setTool("");
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
this.setSelection(this.pageItems(index));
|
||||||
|
this.focus();
|
||||||
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
this.closeCrop();
|
this.closeCrop();
|
||||||
@@ -1188,6 +1212,10 @@ export class CanvasEditor {
|
|||||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||||
clientX: event.clientX, clientY: event.clientY,
|
clientX: event.clientX, clientY: event.clientY,
|
||||||
};
|
};
|
||||||
|
const extent = this.drag.extent = document.createElement("div");
|
||||||
|
Object.assign(extent.style, { position: "absolute", left: "0", top: "0", pointerEvents: "none",
|
||||||
|
width: `${this.viewer.scrollWidth}px`, height: `${this.viewer.scrollHeight}px` });
|
||||||
|
this.viewer.append(extent);
|
||||||
if (!this.drag.corner) {
|
if (!this.drag.corner) {
|
||||||
const selected = this.items();
|
const selected = this.items();
|
||||||
const targets = [...item.surface.querySelectorAll(".edit-object")]
|
const targets = [...item.surface.querySelectorAll(".edit-object")]
|
||||||
@@ -1294,6 +1322,7 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
const from = drag.origin ||= pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||||
|
this.autoScroll(drag);
|
||||||
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
||||||
if (drag.item.textFrame) {
|
if (drag.item.textFrame) {
|
||||||
const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix;
|
const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix;
|
||||||
@@ -1303,6 +1332,7 @@ export class CanvasEditor {
|
|||||||
const bounds = transformedBox(box, frame.matrix);
|
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`,
|
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` });
|
width: `${bounds.width * PX_PER_MM}px`, height: `${bounds.height * PX_PER_MM}px` });
|
||||||
|
this.placeTextHandles(drag.item, box, bounds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, 0, drag.corner, false);
|
||||||
@@ -1338,14 +1368,13 @@ export class CanvasEditor {
|
|||||||
drag.axis = event.shiftKey ? Math.abs(drag.change.x) >= Math.abs(drag.change.y) ? "x" : "y" : "";
|
drag.axis = event.shiftKey ? Math.abs(drag.change.x) >= Math.abs(drag.change.y) ? "x" : "y" : "";
|
||||||
if (drag.axis) drag.change[drag.axis === "x" ? "y" : "x"] = 0;
|
if (drag.axis) drag.change[drag.axis === "x" ? "y" : "x"] = 0;
|
||||||
this.snap(drag, event.altKey);
|
this.snap(drag, event.altKey);
|
||||||
this.autoScroll(drag);
|
|
||||||
}
|
}
|
||||||
this.place(drag.item, drag.change);
|
this.place(drag.item, drag.change);
|
||||||
}
|
}
|
||||||
|
|
||||||
scroll() {
|
scroll() {
|
||||||
const drag = this.drag;
|
const drag = this.drag;
|
||||||
if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) {
|
if (!drag || !drag.item && !drag.marquee || !drag.pointer) {
|
||||||
this.cancel();
|
this.cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1460,6 +1489,7 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
if (drag.corner && drag.box) {
|
if (drag.corner && drag.box) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
|
drag.extent?.remove();
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
const frame = drag.item.textFrame;
|
const frame = drag.item.textFrame;
|
||||||
const geometry = drag.item.oriented?.box || drag.item.geometry || (frame ? { x: drag.item.x, width: textFrameBox(drag.item).width } : drag.item);
|
const geometry = drag.item.oriented?.box || drag.item.geometry || (frame ? { x: drag.item.x, width: textFrameBox(drag.item).width } : drag.item);
|
||||||
@@ -1473,6 +1503,7 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
|
drag.extent?.remove();
|
||||||
drag.guides?.preview.remove();
|
drag.guides?.preview.remove();
|
||||||
this.viewer.releasePointerCapture(drag.pointerID);
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
|
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
|
||||||
@@ -1483,6 +1514,7 @@ export class CanvasEditor {
|
|||||||
const drag = this.drag;
|
const drag = this.drag;
|
||||||
this.drag = null;
|
this.drag = null;
|
||||||
if (drag) {
|
if (drag) {
|
||||||
|
drag.extent?.remove();
|
||||||
for (const item of drag.erased || []) item.node.classList.remove("erasing");
|
for (const item of drag.erased || []) item.node.classList.remove("erasing");
|
||||||
if (drag.crop) {
|
if (drag.crop) {
|
||||||
this.crop.box = drag.box;
|
this.crop.box = drag.box;
|
||||||
@@ -1724,8 +1756,8 @@ export class CanvasEditor {
|
|||||||
return this.nudgeCommit;
|
return this.nudgeCommit;
|
||||||
}
|
}
|
||||||
|
|
||||||
keyDown(event) {
|
keyDown(event, toolbar = false) {
|
||||||
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
if (event.defaultPrevented || !toolbar && event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||||
if (this.crop && !this.options.busy()) {
|
if (this.crop && !this.options.busy()) {
|
||||||
if (event.key === "Escape" || event.key === "Enter") {
|
if (event.key === "Escape" || event.key === "Enter") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1935,7 +1967,9 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
input.addEventListener("keydown", (event) => {
|
input.addEventListener("keydown", (event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
|
if (event.key === "F3" && !event.ctrlKey && !event.metaKey && !event.altKey) return;
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && (["s", "w", "tab"].includes(event.key.toLowerCase())
|
||||||
|
|| !event.shiftKey && ["f", "o", "p"].includes(event.key.toLowerCase()))) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.isComposing || editing.composing || editing.saving) {
|
if (event.isComposing || editing.composing || editing.saving) {
|
||||||
return;
|
return;
|
||||||
@@ -2294,12 +2328,19 @@ export class CanvasEditor {
|
|||||||
async commitText() {
|
async commitText() {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
if (!editing) return true;
|
if (!editing) return true;
|
||||||
if (editing.saving) return false;
|
if (editing.saving || editing.preparingCommit) return false;
|
||||||
if (editing.composing) {
|
if (editing.composing) {
|
||||||
editing.commitAfterComposition = true;
|
editing.commitAfterComposition = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
editing.commitAfterComposition = 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);
|
const value = editTextValue(editing.input);
|
||||||
if (!this.textChanged()) {
|
if (!this.textChanged()) {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
|
|||||||
+100
-23
@@ -1,17 +1,18 @@
|
|||||||
const DATABASE = "ofdgo";
|
const DATABASE = "ofdgo";
|
||||||
|
|
||||||
export class FontPicker {
|
export class FontPicker {
|
||||||
constructor(input, toggle, list, onChange, onOpen) {
|
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
|
||||||
input.addEventListener("input", () => this.show(input.value));
|
input.addEventListener("input", () => this.show(input.value));
|
||||||
input.addEventListener("focus", () => input.select());
|
input.addEventListener("focus", () => input.select());
|
||||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
|
||||||
|
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
|
||||||
input.addEventListener("keydown", event => this.keyDown(event));
|
input.addEventListener("keydown", event => this.keyDown(event));
|
||||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||||
toggle.addEventListener("click", () => {
|
toggle.addEventListener("click", () => {
|
||||||
if (this.open) this.close();
|
if (this.open) this.close();
|
||||||
else { input.focus(); this.show(); }
|
else { input.focus(); input.select(); this.show(); }
|
||||||
});
|
});
|
||||||
list.addEventListener("pointerdown", event => {
|
list.addEventListener("pointerdown", event => {
|
||||||
if (event.target.closest(".font-option")) event.preventDefault();
|
if (event.target.closest(".font-option")) event.preventDefault();
|
||||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
|||||||
window.addEventListener("resize", () => this.close());
|
window.addEventListener("resize", () => this.close());
|
||||||
}
|
}
|
||||||
|
|
||||||
setFonts(fonts, selected) {
|
setFonts(fonts, selected, preserve = false) {
|
||||||
this.close();
|
const open = preserve && this.open;
|
||||||
|
if (!open) this.close();
|
||||||
this.fonts = fonts;
|
this.fonts = fonts;
|
||||||
|
this.groups = fonts.map((_, index) => [index]);
|
||||||
this.names = new Map();
|
this.names = new Map();
|
||||||
for (const font of fonts) {
|
for (const font of fonts) {
|
||||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||||
}
|
}
|
||||||
const key = font => font?.id || font?.postscriptName;
|
const key = font => font?.richKey || font?.id || font?.postscriptName;
|
||||||
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
||||||
this.restore();
|
if (open) {
|
||||||
|
const active = this.matches[this.active]?.font;
|
||||||
|
this.show(this.query, false);
|
||||||
|
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
|
||||||
|
if (index >= 0) this.activate(index);
|
||||||
|
} else this.restore();
|
||||||
|
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
|
||||||
|
if (this.fonts !== fonts) return;
|
||||||
|
this.groups = groups;
|
||||||
|
this.names.clear();
|
||||||
|
for (const group of groups) {
|
||||||
|
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||||
|
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||||
|
}
|
||||||
|
if (this.open) {
|
||||||
|
const active = this.matches[this.active]?.index;
|
||||||
|
this.show(this.query, false);
|
||||||
|
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
|
||||||
|
if (index >= 0) this.activate(index);
|
||||||
|
}
|
||||||
|
else this.restore();
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisabled(disabled) {
|
setDisabled(disabled) {
|
||||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
|||||||
|
|
||||||
restore() {
|
restore() {
|
||||||
const font = this.fonts[Number(this.value)];
|
const font = this.fonts[Number(this.value)];
|
||||||
this.input.value = font?.fullName || font?.name || "";
|
const value = font?.menuFamily || font?.fullName || font?.name || "";
|
||||||
|
if (this.input.value !== value) {
|
||||||
|
this.input.value = value;
|
||||||
|
if (document.activeElement === this.input) this.input.select();
|
||||||
|
}
|
||||||
this.input.scrollLeft = 0;
|
this.input.scrollLeft = 0;
|
||||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceLabel(font) {
|
sourceLabel(font) {
|
||||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||||
if (this.names.get(name) < 2) return "";
|
if (this.names.get(name) < 2) return "";
|
||||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||||
}
|
}
|
||||||
|
|
||||||
show(query = "", load = true) {
|
show(query = "", load = true) {
|
||||||
if (this.input.disabled) return;
|
if (this.input.disabled) return;
|
||||||
|
this.query = query;
|
||||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||||
const terms = normalize(query).split(" ").filter(Boolean);
|
const terms = normalize(query).split(" ").filter(Boolean);
|
||||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
this.matches = this.groups.map(group => {
|
||||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
|
||||||
|
return { font: this.fonts[index], index, group };
|
||||||
|
}).filter(({ group }) => {
|
||||||
|
const name = normalize(group.flatMap(index => {
|
||||||
|
const font = this.fonts[index];
|
||||||
|
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
|
||||||
|
}).filter(Boolean).join(" "));
|
||||||
return terms.every(term => name.includes(term));
|
return terms.every(term => name.includes(term));
|
||||||
});
|
});
|
||||||
this.list.replaceChildren();
|
this.list.replaceChildren();
|
||||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
|||||||
option.dataset.index = String(index);
|
option.dataset.index = String(index);
|
||||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.textContent = font.fullName || font.name;
|
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||||
option.append(name);
|
option.append(name);
|
||||||
const source = this.sourceLabel(font);
|
const source = this.sourceLabel(font);
|
||||||
if (source) {
|
if (source) {
|
||||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
|||||||
if (!this.open) this.list.showPopover();
|
if (!this.open) this.list.showPopover();
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.position();
|
this.position();
|
||||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
|
||||||
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||||
if (load) this.onOpen?.();
|
if (load) this.onOpen?.();
|
||||||
}
|
}
|
||||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
|||||||
const changed = this.value !== String(index);
|
const changed = this.value !== String(index);
|
||||||
this.value = String(index);
|
this.value = String(index);
|
||||||
this.close();
|
this.close();
|
||||||
if (changed) return this.onChange();
|
if (changed) {
|
||||||
|
const task = this.changing = Promise.resolve(this.onChange());
|
||||||
|
return task.finally(() => { if (this.changing === task) this.changing = null; });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
commitExact() {
|
commitExact() {
|
||||||
const value = this.input.value.trim().toLocaleLowerCase();
|
const value = this.input.value.trim().toLocaleLowerCase();
|
||||||
const matches = font => font && !font.disabled
|
const matches = font => font && !font.disabled
|
||||||
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||||
const selected = Number(this.value);
|
const selected = Number(this.value);
|
||||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
||||||
if (index >= 0) this.choose(index);
|
if (index >= 0) return this.choose(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCommit(event) {
|
||||||
|
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
|
||||||
|
this.commitExact();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
return this.changing;
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
@@ -137,7 +183,7 @@ export class FontPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyDown(event) {
|
keyDown(event) {
|
||||||
if (event.isComposing) return;
|
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
|
||||||
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -314,13 +360,44 @@ export class FontManager {
|
|||||||
}), ...this.catalog];
|
}), ...this.catalog];
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFaces(inspect) {
|
async loadFaces(inspect, current = () => true) {
|
||||||
while (true) {
|
while (current()) {
|
||||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
||||||
if (!file) return;
|
if (!file) break;
|
||||||
try { file.faces = await inspect(await this.read(file)); }
|
try { file.faces = await inspect(await this.read(file)); }
|
||||||
catch { file.faces = []; }
|
catch { if (!current()) return; file.faces = []; }
|
||||||
}
|
}
|
||||||
|
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
|
||||||
|
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||||
|
while (current() && pending.length) {
|
||||||
|
const font = pending.shift();
|
||||||
|
try {
|
||||||
|
const faces = await inspect(await this.read(font));
|
||||||
|
const face = faces.find(face => face.postscriptName === font.postscriptName);
|
||||||
|
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
|
||||||
|
else font.metadataFailed = true;
|
||||||
|
} catch { if (current()) font.metadataFailed = true; }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async defaultFont(order, inspect, current = () => true) {
|
||||||
|
const fonts = this.editorFonts().filter(font => !font.disabled);
|
||||||
|
if (!fonts.length) return;
|
||||||
|
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
|
||||||
|
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
|
||||||
|
const indices = await order(fonts, families);
|
||||||
|
let fallback = fonts.length;
|
||||||
|
for (const index of indices) {
|
||||||
|
if (!current()) return;
|
||||||
|
try {
|
||||||
|
await inspect(fonts[index], "中文汉字编辑水印,。!?");
|
||||||
|
if (current()) return fonts[index];
|
||||||
|
} catch (error) {
|
||||||
|
if (error.missingGlyphs) fallback = Math.min(fallback, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current()) return fonts[fallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(fonts) {
|
async add(fonts) {
|
||||||
|
|||||||
@@ -292,6 +292,15 @@ export function createPageView(context) {
|
|||||||
unmountPage(index) {
|
unmountPage(index) {
|
||||||
const shell = pageView.pageShell(index);
|
const shell = pageView.pageShell(index);
|
||||||
if (!shell) return;
|
if (!shell) return;
|
||||||
|
if (context.state.editing && shell.classList.contains("rendered")) {
|
||||||
|
const bounds = shell.getBoundingClientRect(), viewport = context.el.viewerPanel.getBoundingClientRect();
|
||||||
|
const margin = context.state.pageWindow?.overscan ?? 600;
|
||||||
|
const bottom = Math.max(bounds.bottom, bounds.top + shell.scrollHeight);
|
||||||
|
if (bottom >= viewport.top - margin && bounds.top <= viewport.bottom + margin) {
|
||||||
|
context.state.selectedPages.add(index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ package font
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,6 +24,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
// Face 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称
|
||||||
|
// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式
|
||||||
type Face struct {
|
type Face struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
@@ -30,6 +33,11 @@ type Face struct {
|
|||||||
Style string `json:"style"`
|
Style string `json:"style"`
|
||||||
PostScriptName string `json:"postscriptName"`
|
PostScriptName string `json:"postscriptName"`
|
||||||
Names []string `json:"names"`
|
Names []string `json:"names"`
|
||||||
|
MenuFamily string `json:"menuFamily"`
|
||||||
|
MenuStyle string `json:"menuStyle"`
|
||||||
|
FamilyKey string `json:"familyKey"`
|
||||||
|
Bold bool `json:"bold"`
|
||||||
|
Italic bool `json:"italic"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FaceInfo 从名称表读取字体信息
|
// FaceInfo 从名称表读取字体信息
|
||||||
@@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face {
|
|||||||
}
|
}
|
||||||
return name(ids...)
|
return name(ids...)
|
||||||
}
|
}
|
||||||
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)}
|
key := ""
|
||||||
|
if family, version := name(1), name(5); family != "" && version != "" {
|
||||||
|
key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00"))))
|
||||||
|
}
|
||||||
|
family, style := localized(1), localized(2)
|
||||||
|
if family == "" {
|
||||||
|
family = localized(16, 4, 6)
|
||||||
|
}
|
||||||
|
if style == "" {
|
||||||
|
style = localized(17)
|
||||||
|
}
|
||||||
|
return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化
|
||||||
|
// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识
|
||||||
|
// 返回: Face 字体信息
|
||||||
|
func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face {
|
||||||
|
face := faceInfoLanguage(tables["name"], index, platform, language)
|
||||||
|
key := face.FamilyKey
|
||||||
|
if head := tables["head"]; len(head) >= 46 {
|
||||||
|
key += string(head[4:8])
|
||||||
|
style := binary.BigEndian.Uint16(head[44:])
|
||||||
|
face.Bold, face.Italic = style&1 != 0, style&2 != 0
|
||||||
|
}
|
||||||
|
if os2 := tables["OS/2"]; len(os2) >= 64 {
|
||||||
|
key += string(os2[58:62])
|
||||||
|
style := binary.BigEndian.Uint16(os2[62:])
|
||||||
|
face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0
|
||||||
|
}
|
||||||
|
if face.FamilyKey != "" {
|
||||||
|
face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
|
||||||
|
}
|
||||||
|
return face
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileCount 校验字体或集合头并读取字体数量
|
// FileCount 校验字体或集合头并读取字体数量
|
||||||
@@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
face := faceInfoLanguage(tables["name"], index, platform, language)
|
face := TableFaceInfo(tables, index, platform, language)
|
||||||
if face.FullName == "" {
|
if face.FullName == "" {
|
||||||
return nil, fmt.Errorf("font %d has no usable name", index)
|
return nil, fmt.Errorf("font %d has no usable name", index)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) {
|
|||||||
values := make(map[[4]uint16]string)
|
values := make(map[[4]uint16]string)
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
switch record.Name {
|
switch record.Name {
|
||||||
case 1, 2, 4, 6, 16, 17:
|
case 1, 2, 4, 5, 6, 8, 16, 17:
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-29
@@ -757,6 +757,13 @@ func (s *Session) Details() DocumentDetails {
|
|||||||
}
|
}
|
||||||
info.SignatureCount = len(info.Signatures)
|
info.SignatureCount = len(info.Signatures)
|
||||||
if fonts, err := s.Renderer.DeclaredFontInfos(); err == nil {
|
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.Fonts = fonts
|
||||||
info.FontCount = len(fonts)
|
info.FontCount = len(fonts)
|
||||||
}
|
}
|
||||||
@@ -3172,7 +3179,37 @@ func (e *businessEngine) fontFaces(args []platformValue) (any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 提取编辑时选定的字体,供输入预览和保存共用
|
// fontFace 提取编辑时选定的字体,供输入预览和保存共用
|
||||||
@@ -4926,7 +4963,7 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatText 批量修改选区格式,未指定的属性保持各自原值
|
// formatText 批量修改选区格式,未指定的属性保持各自原值
|
||||||
// 入参: args 页面索引、完整路径数组和格式JSON
|
// 入参: args 页面索引、完整路径数组、格式JSON及可选显式重排标志
|
||||||
// 返回: any 文档信息, error 校验或提交错误
|
// 返回: any 文档信息, error 校验或提交错误
|
||||||
func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
||||||
return e.changeObjects(func() error {
|
return e.changeObjects(func() error {
|
||||||
@@ -4948,10 +4985,72 @@ func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
|||||||
}
|
}
|
||||||
*value = color.Value
|
*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)
|
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 将富文本复合图元作为一个文本框提供给编辑器
|
// editorRichText 将富文本复合图元作为一个文本框提供给编辑器
|
||||||
// 入参: item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称, local 局部编辑框, matrix 页面变换
|
// 入参: 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) {
|
func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string, local ofdgo.Box, matrix ofdgo.Matrix) {
|
||||||
@@ -5151,43 +5250,30 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
|||||||
}
|
}
|
||||||
return e.currentEditor.ReflowRichText(page, path, value)
|
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() != "" {
|
if len(args) > 5 && args[5].String() != "" {
|
||||||
key := args[5].String()
|
key := args[5].String()
|
||||||
path, err := e.compositePath(key)
|
path, err := e.compositePath(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
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)
|
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)
|
id = fmt.Sprintf("%s/%d", key, index)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if id != "" {
|
|
||||||
if err := e.currentEditor.UpdateRichText(page, id, value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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)
|
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
|
return err
|
||||||
})
|
})
|
||||||
@@ -5617,6 +5703,20 @@ func (e *businessEngine) orderObjects(args []platformValue) (any, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distributionAxes 获取画布选区可用的等间距分布方向
|
||||||
|
// 入参: args 页面索引和画布对象标识数组
|
||||||
|
// 返回: any 可用方向, error 路径或度量错误
|
||||||
|
func (e *businessEngine) distributionAxes(args []platformValue) (any, error) {
|
||||||
|
if e.currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being edited")
|
||||||
|
}
|
||||||
|
paths, err := e.editorTargetPaths(e.stringsFromJS(args[1]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.currentEditor.DistributionAxes(args[0].Int(), paths)
|
||||||
|
}
|
||||||
|
|
||||||
// distributeObjects 按指定轴等距分布选区
|
// distributeObjects 按指定轴等距分布选区
|
||||||
// 入参: args 页面索引、对象标识数组和分布轴
|
// 入参: args 页面索引、对象标识数组和分布轴
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
@@ -8005,6 +8105,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
|||||||
"ofdgoMatchFontFiles": e.matchFontFiles,
|
"ofdgoMatchFontFiles": e.matchFontFiles,
|
||||||
"ofdgoChangeFontBinding": e.changeFontBinding,
|
"ofdgoChangeFontBinding": e.changeFontBinding,
|
||||||
"ofdgoFontFaces": e.fontFaces,
|
"ofdgoFontFaces": e.fontFaces,
|
||||||
|
"ofdgoFontMenu": e.fontMenu,
|
||||||
"ofdgoFontFace": e.fontFace,
|
"ofdgoFontFace": e.fontFace,
|
||||||
"ofdgoCreateDocument": e.createDocument,
|
"ofdgoCreateDocument": e.createDocument,
|
||||||
"ofdgoImportImages": e.importImages,
|
"ofdgoImportImages": e.importImages,
|
||||||
@@ -8038,11 +8139,13 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
|||||||
"ofdgoUpdateTextSelection": e.updateTextSelection,
|
"ofdgoUpdateTextSelection": e.updateTextSelection,
|
||||||
"ofdgoStyleText": e.styleText,
|
"ofdgoStyleText": e.styleText,
|
||||||
"ofdgoFormatText": e.formatText,
|
"ofdgoFormatText": e.formatText,
|
||||||
|
"ofdgoStyleTextFonts": e.styleTextFonts,
|
||||||
"ofdgoGetRichText": e.getRichText,
|
"ofdgoGetRichText": e.getRichText,
|
||||||
"ofdgoRichTextFonts": e.richTextFonts,
|
"ofdgoRichTextFonts": e.richTextFonts,
|
||||||
"ofdgoSaveRichText": e.saveRichText,
|
"ofdgoSaveRichText": e.saveRichText,
|
||||||
"ofdgoPreviewRichText": e.previewRichText,
|
"ofdgoPreviewRichText": e.previewRichText,
|
||||||
"ofdgoCheckTextFont": e.checkTextFont,
|
"ofdgoCheckTextFont": e.checkTextFont,
|
||||||
|
"ofdgoFontPreferenceOrder": e.fontPreferenceOrder,
|
||||||
"ofdgoInsertImage": e.insertImage,
|
"ofdgoInsertImage": e.insertImage,
|
||||||
"ofdgoInsertShape": e.insertShape,
|
"ofdgoInsertShape": e.insertShape,
|
||||||
"ofdgoUpdatePathStyle": e.updatePathStyle,
|
"ofdgoUpdatePathStyle": e.updatePathStyle,
|
||||||
@@ -8054,6 +8157,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
|||||||
"ofdgoReshapeLine": e.reshapeLine,
|
"ofdgoReshapeLine": e.reshapeLine,
|
||||||
"ofdgoResetImageCrop": e.resetImageCrop,
|
"ofdgoResetImageCrop": e.resetImageCrop,
|
||||||
"ofdgoLayoutText": e.layoutText,
|
"ofdgoLayoutText": e.layoutText,
|
||||||
|
"ofdgoLayoutTexts": e.layoutTexts,
|
||||||
"ofdgoTransformObjects": e.transformObjects,
|
"ofdgoTransformObjects": e.transformObjects,
|
||||||
"ofdgoCompositeObjects": e.compositeObjects,
|
"ofdgoCompositeObjects": e.compositeObjects,
|
||||||
"ofdgoChangeCompositeObjects": e.changeCompositeObjects,
|
"ofdgoChangeCompositeObjects": e.changeCompositeObjects,
|
||||||
@@ -8069,6 +8173,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
|||||||
"ofdgoImportObjects": e.importObjects,
|
"ofdgoImportObjects": e.importObjects,
|
||||||
"ofdgoOrderObjects": e.orderObjects,
|
"ofdgoOrderObjects": e.orderObjects,
|
||||||
"ofdgoDistributeObjects": e.distributeObjects,
|
"ofdgoDistributeObjects": e.distributeObjects,
|
||||||
|
"ofdgoDistributionAxes": e.distributionAxes,
|
||||||
"ofdgoRotateObjects": e.rotateObjects,
|
"ofdgoRotateObjects": e.rotateObjects,
|
||||||
"ofdgoFlipObjects": e.flipObjects,
|
"ofdgoFlipObjects": e.flipObjects,
|
||||||
"ofdgoCropImage": e.cropImage,
|
"ofdgoCropImage": e.cropImage,
|
||||||
|
|||||||
@@ -1049,7 +1049,7 @@ func (h *Host) readFonts() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, face := range faces {
|
for _, face := range faces {
|
||||||
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file})
|
h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file})
|
||||||
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) {
|
||||||
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
if alias != "" && h.fontFiles[alias+".ttf"] == "" {
|
||||||
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index)
|
||||||
|
|||||||
+1
-4
@@ -800,10 +800,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := LayoutText(obj, value, options, metrics); err != nil {
|
return LayoutText(obj, value, options, metrics)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
// newRenderer 使用编辑器字体来源和完整后端配置创建资源度量器
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排
|
// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按排版选项重排
|
||||||
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
||||||
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性
|
||||||
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
||||||
@@ -29,7 +29,7 @@ func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value
|
|||||||
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
return e.changeCompositeText(page, path, []int{index}, &value, style, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位
|
// StyleCompositeText 批量修改内部文字字体、字号或颜色,已知段落重排,原文保留定位
|
||||||
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
// 更换字体时同步字形索引,无法逐字映射时需显式重排,缺字使整批操作失败
|
||||||
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -38,7 +38,7 @@ func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
// LayoutCompositeText 显式重排内部普通文字,保留原始基线、变换、裁剪和绘制属性
|
||||||
// 段落选项仅保留在编辑会话中,撤销重做随对象恢复,保存为标准TextCode定位
|
// 段落选项随标准TextCode定位保存,撤销重做及重新打开时恢复
|
||||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
// 入参: page 页面索引, path 父路径, index 成员序号, value 原文, options 段落选项
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
|
func (e *Editor) LayoutCompositeText(page int, path ObjectPath, index int, value string, options TextLayout) error {
|
||||||
@@ -129,7 +129,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.TextCode = text.TextCode
|
object.TextObject.TextCode = text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
object.TextObject.CGTransform = text.CGTransform
|
||||||
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && state.layout.options.Shape && style.Font != "" && style.Font != object.TextObject.Font {
|
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size || state.layout != nil && style.Font != "" && style.Font != object.TextObject.Font {
|
||||||
if style.Size != 0 {
|
if style.Size != 0 {
|
||||||
text.Size = style.Size
|
text.Size = style.Size
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,10 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode
|
||||||
object.TextObject.CGTransform = text.CGTransform
|
object.TextObject.CGTransform = text.CGTransform
|
||||||
state.layout = &textLayout{value: content, options: layout}
|
if frame != nil {
|
||||||
|
text.layout.autoWidth = false
|
||||||
|
}
|
||||||
|
state.layout = text.layout
|
||||||
}
|
}
|
||||||
if style.Font != "" && style.Font != object.TextObject.Font {
|
if style.Font != "" && style.Font != object.TextObject.Font {
|
||||||
if err := e.prepareText(&text); err != nil {
|
if err := e.prepareText(&text); err != nil {
|
||||||
|
|||||||
@@ -968,7 +968,7 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s
|
|||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||||
}
|
}
|
||||||
for _, attr := range node.Attr {
|
for _, attr := range node.Attr {
|
||||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(node.Name, attr) {
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.Name, attr) {
|
||||||
uncertain = true
|
uncertain = true
|
||||||
return fmt.Errorf("unknown file dependencies in %s", name)
|
return fmt.Errorf("unknown file dependencies in %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, attr := range node.attrs {
|
for _, attr := range node.attrs {
|
||||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(node.name, attr) {
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(node.name, attr) {
|
||||||
scan.unsafe = true
|
scan.unsafe = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func (s *editorReferenceScan) accept(token xml.Token) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, attr := range token.Attr {
|
for _, attr := range token.Attr {
|
||||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textLayoutAttribute(token.Name, attr) {
|
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textMetadataAttribute(token.Name, attr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags"
|
custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags"
|
||||||
|
|||||||
@@ -105,6 +105,24 @@ func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
|
|||||||
return e.updateRichTextFrame(page, id, value, 0, 0, false)
|
return e.updateRichTextFrame(page, id, value, 0, 0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTargetRichText 一次提交正文或组合内富文本的内容和局部宽度,保留页面变换
|
||||||
|
// 入参: page 页面索引, path 完整对象路径, value 富文本, offset 局部偏移, width 宽度,零值保留宽度模式
|
||||||
|
// 返回: error 路径、尺寸或排版错误
|
||||||
|
func (e *Editor) UpdateTargetRichText(page int, path ObjectPath, value RichText, offset, width float64) error {
|
||||||
|
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||||
|
return fmt.Errorf("invalid text frame")
|
||||||
|
}
|
||||||
|
if len(path.Children) == 0 {
|
||||||
|
if path.Annotation != "" {
|
||||||
|
return ErrNotRichText
|
||||||
|
}
|
||||||
|
return e.updateRichTextFrame(page, path.ID, value, offset, width, false)
|
||||||
|
}
|
||||||
|
index := path.Children[len(path.Children)-1]
|
||||||
|
path.Children = path.Children[:len(path.Children)-1]
|
||||||
|
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||||
|
}
|
||||||
|
|
||||||
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||||
// 入参: page 页面索引, id 对象标识, offset 局部横向偏移, width 新的局部段落宽度
|
// 入参: page 页面索引, id 对象标识, offset 局部横向偏移, width 新的局部段落宽度
|
||||||
// 返回: error 尺寸、排版或提交错误
|
// 返回: error 尺寸、排版或提交错误
|
||||||
@@ -1066,17 +1084,17 @@ func richTextMetadataFontSafe(value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换
|
// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换
|
||||||
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量
|
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量, reflow 显式重排
|
||||||
// 返回: error 识别、排版或提交错误
|
// 返回: error 识别、排版或提交错误
|
||||||
func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch) error {
|
func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch, reflow bool) error {
|
||||||
value, err := e.TargetRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)})
|
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}, reflow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return e.UpdateCompositeRichText(page, path, index, value, 0, 0)
|
return e.updateCompositeRichText(page, path, index, value, 0, 0, reflow)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
|
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
|
||||||
|
|||||||
+181
-3
@@ -808,6 +808,40 @@ func (e *Editor) ResizeTargets(page int, paths []ObjectPath, box Box) error {
|
|||||||
return e.TransformTargets(page, paths, Matrix{a: sx, d: sy, e: box.X - bounds.X*sx, f: box.Y - bounds.Y*sy})
|
return e.TransformTargets(page, paths, Matrix{a: sx, d: sy, e: box.X - bounds.X*sx, f: box.Y - bounds.Y*sy})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DistributionAxes 获取选区可保持位置顺序的等间距分布方向,不修改文档或历史
|
||||||
|
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径
|
||||||
|
// 返回: []string 可用的horizontal和vertical方向, error 路径或度量错误
|
||||||
|
func (e *Editor) DistributionAxes(page int, paths []ObjectPath) ([]string, error) {
|
||||||
|
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var boxes []Box
|
||||||
|
var positions []editorObjectPosition
|
||||||
|
if len(groups) == 0 {
|
||||||
|
boxes, positions, _, err = e.selectionGeometry(page, objects, annotations)
|
||||||
|
} else {
|
||||||
|
boxes, _, _, err = e.targetGeometry(page, paths)
|
||||||
|
positions = make([]editorObjectPosition, len(paths))
|
||||||
|
for i := range positions {
|
||||||
|
positions[i].index = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
axes := []string{}
|
||||||
|
if len(boxes) < 3 {
|
||||||
|
return axes, nil
|
||||||
|
}
|
||||||
|
for _, axis := range []string{"horizontal", "vertical"} {
|
||||||
|
if _, err := editorDistribution(boxes, positions, axis); err == nil {
|
||||||
|
axes = append(axes, axis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return axes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
|
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
|
||||||
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
|
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
|
||||||
// 返回: error 路径、方向或编辑错误
|
// 返回: error 路径、方向或编辑错误
|
||||||
@@ -886,6 +920,150 @@ func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextForma
|
|||||||
return e.formatTargetsText(page, paths, patch, false)
|
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 统一文字选区格式事务,普通字体和颜色修改沿用定位保留路径
|
// formatTargetsText 统一文字选区格式事务,普通字体和颜色修改沿用定位保留路径
|
||||||
// 入参: page 页面索引, paths 完整路径, patch 格式增量, reflow 显式重排
|
// 入参: page 页面索引, paths 完整路径, patch 格式增量, reflow 显式重排
|
||||||
// 返回: error 校验或编辑错误
|
// 返回: error 校验或编辑错误
|
||||||
@@ -925,14 +1103,14 @@ func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextForma
|
|||||||
plain = append(plain, id)
|
plain = append(plain, id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
value, err := e.richTextObject(page, object)
|
value, _, _, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
if err := value.ApplyFormat(0, value.Len(), patch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := e.UpdateRichText(page, id, value); err != nil {
|
if err := e.updateRichTextFrame(page, id, value, 0, 0, reflow); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -951,7 +1129,7 @@ func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextForma
|
|||||||
plain = append(plain, indexes[i])
|
plain = append(plain, indexes[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := e.formatCompositeText(page, path, indexes[i], patch); err != nil {
|
if err := e.formatCompositeText(page, path, indexes[i], patch, reflow); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -73,27 +73,27 @@ type textLayout struct {
|
|||||||
autoWidth bool
|
autoWidth bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// textLayoutNamespace 标识普通文字排版元数据,不包含资源引用
|
// textNamespace 标识普通文字编辑语义的命名空间,版本仅随不兼容的语义变更升级
|
||||||
const textLayoutNamespace = "https://ofdgo.aite.me/text-layout"
|
const textNamespace = "https://ofdgo.aite.me/ns/text/1"
|
||||||
|
|
||||||
// textLayoutAttribute 判断无资源引用的文字排版扩展属性
|
// textMetadataAttribute 判断无资源引用的普通文字编辑元数据属性
|
||||||
// 入参: node 所属节点, attr 属性
|
// 入参: node 所属节点, attr 属性
|
||||||
// 返回: bool 是否为本库排版属性
|
// 返回: bool 是否为本库普通文字元数据
|
||||||
func textLayoutAttribute(node xml.Name, attr xml.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 == textLayoutNamespace && attr.Name.Local == "Layout"
|
return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textNamespace && attr.Name.Local == "Text"
|
||||||
}
|
}
|
||||||
|
|
||||||
// textLayoutMetadata 保存原文、选项与标准定位摘要
|
// textMetadata 保存原文、选项与标准定位摘要
|
||||||
type textLayoutMetadata struct {
|
type textMetadata struct {
|
||||||
Text string
|
Text string `json:"text"`
|
||||||
Options TextLayout
|
Layout TextLayout `json:"layout"`
|
||||||
AutoWidth bool
|
AutoWidth bool `json:"autoWidth"`
|
||||||
Digest string
|
Digest string `json:"digest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// layoutDigest 计算与字体编号无关的文字定位摘要
|
// textDigest 计算与字体编号无关的文字定位摘要
|
||||||
// 返回: string 摘要
|
// 返回: string 摘要
|
||||||
func (obj TextObject) layoutDigest() string {
|
func (obj TextObject) textDigest() string {
|
||||||
codes := append([]TextCode(nil), obj.TextCode...)
|
codes := append([]TextCode(nil), obj.TextCode...)
|
||||||
for i := range codes {
|
for i := range codes {
|
||||||
codes[i].Value = string(textCodeRunes(codes[i].Value))
|
codes[i].Value = string(textCodeRunes(codes[i].Value))
|
||||||
@@ -102,13 +102,13 @@ func (obj TextObject) layoutDigest() string {
|
|||||||
return fmt.Sprintf("%x", sha256.Sum256(data))
|
return fmt.Sprintf("%x", sha256.Sum256(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// layoutMetadata 编码普通文字的排版语义,与标准定位一起保存
|
// textMetadata 编码普通文字的排版语义,与标准定位一起保存
|
||||||
// 返回: string 元数据, error 编码错误
|
// 返回: string 元数据, error 编码错误
|
||||||
func (obj TextObject) layoutMetadata() (string, error) {
|
func (obj TextObject) textMetadata() (string, error) {
|
||||||
if obj.layout == nil {
|
if obj.layout == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(textLayoutMetadata{Text: obj.layout.value, Options: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.layoutDigest()})
|
data, err := json.Marshal(textMetadata{Text: obj.layout.value, Layout: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.textDigest()})
|
||||||
return string(data), err
|
return string(data), err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -397,7 +397,7 @@ func editorXMLAttributes(node *editorXML, allowed string) bool {
|
|||||||
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if textLayoutAttribute(node.name, attr) {
|
if textMetadataAttribute(node.name, attr) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
|
||||||
@@ -572,15 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes := make(map[string]string)
|
changes := make(map[string]string)
|
||||||
layoutValue := func(n *editorXML) string {
|
textValue := func(n *editorXML) string {
|
||||||
for _, attr := range n.attrs {
|
for _, attr := range n.attrs {
|
||||||
if attr.Name.Space == textLayoutNamespace && attr.Name.Local == "Layout" {
|
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
|
||||||
return attr.Value
|
return attr.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
layoutChanged := layoutValue(oldNode) != layoutValue(newNode)
|
textChanged := textValue(oldNode) != textValue(newNode)
|
||||||
if legacyClip {
|
if legacyClip {
|
||||||
changes["TransFlag"] = ""
|
changes["TransFlag"] = ""
|
||||||
}
|
}
|
||||||
@@ -590,7 +590,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
|
contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
|
||||||
if len(changes) == 0 && !layoutChanged && !contentChanged && namespace == node.name.Space {
|
if len(changes) == 0 && !textChanged && !contentChanged && namespace == node.name.Space {
|
||||||
return bytes.Clone(data[node.start:node.end]), nil
|
return bytes.Clone(data[node.start:node.end]), nil
|
||||||
}
|
}
|
||||||
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
|
||||||
@@ -599,12 +599,12 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
start := token.(xml.StartElement)
|
start := token.(xml.StartElement)
|
||||||
if layoutChanged {
|
if textChanged {
|
||||||
value, found := layoutValue(newNode), false
|
value, found := textValue(newNode), false
|
||||||
attrs := start.Attr[:0]
|
attrs := start.Attr[:0]
|
||||||
for i, attr := range start.Attr {
|
for i, attr := range start.Attr {
|
||||||
resolved := node.attrs[i]
|
resolved := node.attrs[i]
|
||||||
if resolved.Name.Space == textLayoutNamespace && resolved.Name.Local == "Layout" {
|
if resolved.Name.Space == textNamespace && resolved.Name.Local == "Text" {
|
||||||
found = true
|
found = true
|
||||||
if value != "" {
|
if value != "" {
|
||||||
attr.Value = value
|
attr.Value = value
|
||||||
@@ -617,7 +617,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte
|
|||||||
start.Attr = attrs
|
start.Attr = attrs
|
||||||
if !found && value != "" {
|
if !found && value != "" {
|
||||||
prefix := editorXMLNamespacePrefix(node, "ofdgo")
|
prefix := editorXMLNamespacePrefix(node, "ofdgo")
|
||||||
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textLayoutNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Layout"}, Value: value})
|
start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Text"}, Value: value})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if legacyClip || namespace != node.name.Space {
|
if legacyClip || namespace != node.name.Space {
|
||||||
|
|||||||
@@ -17,14 +17,112 @@ package ofdgo
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"sort"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
basefont "github.com/xiaoqidun/ofdgo/internal/base/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
// FontFace 字体文件中的字体名称与零起始索引,不依赖文件名
|
||||||
|
// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称
|
||||||
|
// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式
|
||||||
type FontFace = basefont.Face
|
type FontFace = basefont.Face
|
||||||
|
|
||||||
|
// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口
|
||||||
|
// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源
|
||||||
|
// 返回: [][]int 按首次出现顺序分组的候选索引
|
||||||
|
func FontFamilyIndexes(faces []FontFace, sources []string) [][]int {
|
||||||
|
groups := make([][]int, 0, len(faces))
|
||||||
|
keys := make([]string, len(faces))
|
||||||
|
styles := make(map[string]map[[2]bool]bool)
|
||||||
|
ambiguous := make(map[string]bool)
|
||||||
|
for index, face := range faces {
|
||||||
|
key := face.FamilyKey
|
||||||
|
if len(sources) > index {
|
||||||
|
key += "\x00" + sources[index]
|
||||||
|
}
|
||||||
|
keys[index] = key
|
||||||
|
if styles[key] == nil {
|
||||||
|
styles[key] = make(map[[2]bool]bool)
|
||||||
|
}
|
||||||
|
style := [2]bool{face.Bold, face.Italic}
|
||||||
|
ambiguous[key] = ambiguous[key] || styles[key][style]
|
||||||
|
styles[key][style] = true
|
||||||
|
}
|
||||||
|
indexes := make(map[string]int)
|
||||||
|
for index, face := range faces {
|
||||||
|
key := keys[index]
|
||||||
|
if face.FamilyKey == "" || ambiguous[key] {
|
||||||
|
groups = append(groups, []int{index})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group, exists := indexes[key]
|
||||||
|
if !exists {
|
||||||
|
group, indexes[key] = len(groups), len(groups)
|
||||||
|
groups = append(groups, nil)
|
||||||
|
}
|
||||||
|
groups[group] = append(groups[group], index)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式
|
||||||
|
// 入参: faces 同来源同族候选, bold 粗体, italic 斜体
|
||||||
|
// 返回: int 候选索引,无可用款式时为-1
|
||||||
|
func FontStyleIndex(faces []FontFace, bold, italic bool) int {
|
||||||
|
selected, best := -1, -1
|
||||||
|
for index, face := range faces {
|
||||||
|
if face.Bold && !bold || face.Italic && !italic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := 0
|
||||||
|
if face.Bold == bold {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if face.Italic == italic {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if score > best {
|
||||||
|
selected, best = index, score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序
|
||||||
|
// 入参: faces 候选字体, families 按优先级排列的字体族别名组
|
||||||
|
// 返回: []int 候选字体的零起始索引,不修改输入列表
|
||||||
|
func FontPreferenceOrder(faces []FontFace, families [][]string) []int {
|
||||||
|
priorities := make(map[string]int)
|
||||||
|
for priority, names := range families {
|
||||||
|
for _, name := range names {
|
||||||
|
key := FontNormalizeName(name)
|
||||||
|
if _, exists := priorities[key]; key != "" && !exists {
|
||||||
|
priorities[key] = priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order, ranks := make([]int, len(faces)), make([]int, len(faces))
|
||||||
|
for i, face := range faces {
|
||||||
|
order[i], ranks[i] = i, len(families)*2
|
||||||
|
names := append([]string{face.Family, face.FullName, face.PostScriptName}, face.Names...)
|
||||||
|
for _, name := range names {
|
||||||
|
if priority, exists := priorities[FontNormalizeName(name)]; exists && priority*2 < ranks[i] {
|
||||||
|
ranks[i] = priority * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ranks[i] < len(families)*2 {
|
||||||
|
switch FontNormalizeName(face.Style) {
|
||||||
|
case "", "regular", "normal", "roman", "常规", "正常", "标准":
|
||||||
|
default:
|
||||||
|
ranks[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(order, func(i, j int) bool { return ranks[order[i]] < ranks[order[j]] })
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
// FixFontDataAggressive 激进修复字体数据
|
// FixFontDataAggressive 激进修复字体数据
|
||||||
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
// 尝试修复缺失表(OS/2, cmap等)的TrueType字体或包装CFF裸数据
|
||||||
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
// 对显式字形映射补充私有字符cmap,便于按glyph id渲染
|
||||||
|
|||||||
+41
-1
@@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) {
|
|||||||
return r.fontData(id, false)
|
return r.fontData(id, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体
|
||||||
|
// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识
|
||||||
|
// 返回: *FontFace 独立字体信息, error 资源或解析错误
|
||||||
|
func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) {
|
||||||
|
if r.fontDefinition(id) == nil {
|
||||||
|
if _, err := r.Fonts(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
font := r.fontDefinition(id)
|
||||||
|
if font == nil || font.FontFile == "" {
|
||||||
|
return nil, fmt.Errorf("embedded font %q not found", id)
|
||||||
|
}
|
||||||
|
name := r.ResPath(font.FontFile)
|
||||||
|
key := fmt.Sprintf("%s\x00%d:%d", name, platform, language)
|
||||||
|
faces, ok := r.fontFaces[key]
|
||||||
|
if !ok {
|
||||||
|
data, err := r.readFileView(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, err := (FontFile{Data: data}).LocalizedFaces(platform, language)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
faces = make([]*FontFace, len(values))
|
||||||
|
for index := range values {
|
||||||
|
faces[index] = &values[index]
|
||||||
|
}
|
||||||
|
r.fontFaces[key] = faces
|
||||||
|
}
|
||||||
|
names := make([][]string, len(faces))
|
||||||
|
for index, face := range faces {
|
||||||
|
names[index] = face.Names
|
||||||
|
}
|
||||||
|
face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)]
|
||||||
|
face.Names = slices.Clone(face.Names)
|
||||||
|
return &face, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
// fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取
|
||||||
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
// 入参: id 字体资源标识, readonly 是否允许借用只读资源
|
||||||
// 返回: []byte 字体数据, error 资源或集合错误
|
// 返回: []byte 字体数据, error 资源或集合错误
|
||||||
@@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) {
|
|||||||
faces = make([]*FontFace, count)
|
faces = make([]*FontFace, count)
|
||||||
for index := range faces {
|
for index := range faces {
|
||||||
if tables, err := basefont.FileTables(data, index); err == nil {
|
if tables, err := basefont.FileTables(data, index); err == nil {
|
||||||
info := basefont.FaceInfo(tables["name"], index)
|
info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff)
|
||||||
faces[index] = &info
|
faces[index] = &info
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string
|
|||||||
return false, false, nil, nil
|
return false, false, nil, nil
|
||||||
}
|
}
|
||||||
for _, attr := range token.Attr {
|
for _, attr := range token.Attr {
|
||||||
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(token.Name, attr) {
|
if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textMetadataAttribute(token.Name, attr) {
|
||||||
return false, false, nil, nil
|
return false, false, nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -432,10 +432,10 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement
|
|||||||
}
|
}
|
||||||
*obj = TextObject(value)
|
*obj = TextObject(value)
|
||||||
for _, attr := range start.Attr {
|
for _, attr := range start.Attr {
|
||||||
if attr.Name.Space == textLayoutNamespace && attr.Name.Local == "Layout" {
|
if attr.Name.Space == textNamespace && attr.Name.Local == "Text" {
|
||||||
var meta textLayoutMetadata
|
var meta textMetadata
|
||||||
if json.Unmarshal([]byte(attr.Value), &meta) == nil && validateRichLayout(meta.Options) == nil && structureXMLText(meta.Text) && meta.Digest == obj.layoutDigest() {
|
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.Options, autoWidth: meta.AutoWidth}
|
obj.layout = &textLayout{value: meta.Text, options: meta.Layout, autoWidth: meta.AutoWidth}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type configuredTextShaper struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
|
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
|
||||||
// 不修改文档,通过AddObject或UpdateObject提交;选项仅供当前编辑过程使用,保存为标准文字定位
|
// 不修改文档,通过AddObject或UpdateObject提交;选项随标准文字定位保存,重新打开时恢复
|
||||||
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
|
// 入参: obj 文字对象, value 原文, options 段落排版选项, metrics 字体度量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
|
func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontMetrics) error {
|
||||||
@@ -143,7 +143,11 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
|
|||||||
}
|
}
|
||||||
obj.TextCode = codes
|
obj.TextCode = codes
|
||||||
obj.CGTransform = nil
|
obj.CGTransform = nil
|
||||||
obj.layout = &textLayout{value: value, options: options, autoWidth: options == (TextLayout{})}
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1242,14 +1242,14 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
|
|||||||
switch object.Type {
|
switch object.Type {
|
||||||
case "TextObject", "Text":
|
case "TextObject", "Text":
|
||||||
obj := object.TextObject
|
obj := object.TextObject
|
||||||
metadata, err := obj.layoutMetadata()
|
metadata, err := obj.textMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
x.err = err
|
x.err = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if metadata != "" {
|
if metadata != "" {
|
||||||
attrs.add("xmlns:ofdgo", textLayoutNamespace)
|
attrs.add("xmlns:ofdgo", textNamespace)
|
||||||
attrs.add("ofdgo:Layout", metadata)
|
attrs.add("ofdgo:Text", metadata)
|
||||||
}
|
}
|
||||||
attrs.add("ID", obj.ID)
|
attrs.add("ID", obj.ID)
|
||||||
attrs.add("DrawParam", obj.DrawParam)
|
attrs.add("DrawParam", obj.DrawParam)
|
||||||
|
|||||||
Reference in new issue
Block a user