mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
Compare commits
38
Commits
f2c71c5acf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385d7aa531 | ||
|
|
94157b10c3 | ||
|
|
aebcda51a3 | ||
|
|
19084bb40b | ||
|
|
545b697310 | ||
|
|
1a25472b43 | ||
|
|
af80fcc9ba | ||
|
|
838a769c5b | ||
|
|
5b4f2472f7 | ||
|
|
fb51fd3b07 | ||
|
|
80e548f93e | ||
|
|
9c3a04b7eb | ||
|
|
9bb6c9141a | ||
|
|
4320b53d4a | ||
|
|
e5d7f577c7 | ||
|
|
7a23dcc6dd | ||
|
|
12aaada982 | ||
|
|
62631472ea | ||
|
|
9d6bf3e87f | ||
|
|
f8c8259502 | ||
|
|
f4429b126a | ||
|
|
50898934dc | ||
|
|
c2b0339fdf | ||
|
|
e5658de69d | ||
|
|
057bcba868 | ||
|
|
89a7ef04c7 | ||
|
|
e35533859c | ||
|
|
1215f7aa08 | ||
|
|
48d1d29b7e | ||
|
|
ba68acbc60 | ||
|
|
a75b90dffc | ||
|
|
da3f64c74a | ||
|
|
7e3f2c468a | ||
|
|
d384527371 | ||
|
|
2dcbafb70d | ||
|
|
f246b0f543 | ||
|
|
dfea3d60fd | ||
|
|
e91e146b50 |
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
+596
-213
File diff suppressed because it is too large.
Load diff
+295
-75
@@ -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) {
|
||||
@@ -104,6 +189,8 @@ export class RichTextSession {
|
||||
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]));
|
||||
@@ -149,6 +236,10 @@ 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]));
|
||||
}
|
||||
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];
|
||||
@@ -156,11 +247,17 @@ export class RichTextSession {
|
||||
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 && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return;
|
||||
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;
|
||||
@@ -172,6 +269,12 @@ export class RichTextSession {
|
||||
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.typing = { ...entry.typing };
|
||||
this.caret = [...entry.caret];
|
||||
@@ -187,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;
|
||||
@@ -202,21 +306,37 @@ export class RichTextSession {
|
||||
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.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]);
|
||||
@@ -242,6 +362,7 @@ export class RichTextSession {
|
||||
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;
|
||||
@@ -256,7 +377,12 @@ export class RichTextSession {
|
||||
const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join("");
|
||||
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 clipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(),
|
||||
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;
|
||||
@@ -278,28 +404,34 @@ export class RichTextSession {
|
||||
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, caret = this.selection();
|
||||
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.selection().join() !== caret.join()) return;
|
||||
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(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, lines = [[]], positions = [];
|
||||
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");
|
||||
for (let i = 0; i < pieces.length; i++) {
|
||||
@@ -308,11 +440,15 @@ export class RichTextSession {
|
||||
}
|
||||
}
|
||||
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 key = JSON.stringify(parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family]));
|
||||
let paragraph = previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null;
|
||||
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) {
|
||||
@@ -328,21 +464,29 @@ export class RichTextSession {
|
||||
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);
|
||||
if (index) offset++;
|
||||
for (const span of paragraph.children) {
|
||||
if (span.nodeName !== "SPAN") continue;
|
||||
const node = span.firstChild;
|
||||
positions.push({ node, start: offset, end: offset + node.textContent.length });
|
||||
offset += node.textContent.length;
|
||||
}
|
||||
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";
|
||||
this.editor.fitTextInput();
|
||||
this.editing.range = null;
|
||||
input.focus({ preventScroll: true });
|
||||
restoreTextSelection(positions, this.caret);
|
||||
@@ -358,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);
|
||||
@@ -616,6 +761,14 @@ export function selectedText(item) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -830,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)";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -928,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}`;
|
||||
@@ -968,6 +1122,14 @@ export class CanvasEditor {
|
||||
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() {
|
||||
this.closeText();
|
||||
this.closeCrop();
|
||||
@@ -1050,6 +1212,10 @@ export class CanvasEditor {
|
||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
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) {
|
||||
const selected = this.items();
|
||||
const targets = [...item.surface.querySelectorAll(".edit-object")]
|
||||
@@ -1156,15 +1322,17 @@ export class CanvasEditor {
|
||||
}
|
||||
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);
|
||||
this.autoScroll(drag);
|
||||
if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) {
|
||||
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);
|
||||
@@ -1200,14 +1368,13 @@ export class CanvasEditor {
|
||||
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;
|
||||
this.snap(drag, event.altKey);
|
||||
this.autoScroll(drag);
|
||||
}
|
||||
this.place(drag.item, drag.change);
|
||||
}
|
||||
|
||||
scroll() {
|
||||
const drag = this.drag;
|
||||
if (!drag || !drag.item && !drag.marquee || drag.corner || !drag.pointer) {
|
||||
if (!drag || !drag.item && !drag.marquee || !drag.pointer) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
@@ -1322,9 +1489,10 @@ export class CanvasEditor {
|
||||
}
|
||||
if (drag.corner && drag.box) {
|
||||
this.drag = null;
|
||||
drag.extent?.remove();
|
||||
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(() => {
|
||||
@@ -1335,6 +1503,7 @@ export class CanvasEditor {
|
||||
}
|
||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||
this.drag = null;
|
||||
drag.extent?.remove();
|
||||
drag.guides?.preview.remove();
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
|
||||
@@ -1345,6 +1514,7 @@ export class CanvasEditor {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag) {
|
||||
drag.extent?.remove();
|
||||
for (const item of drag.erased || []) item.node.classList.remove("erasing");
|
||||
if (drag.crop) {
|
||||
this.crop.box = drag.box;
|
||||
@@ -1586,8 +1756,8 @@ export class CanvasEditor {
|
||||
return this.nudgeCommit;
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.defaultPrevented || event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
keyDown(event, toolbar = false) {
|
||||
if (event.defaultPrevented || !toolbar && event.target?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
if (this.crop && !this.options.busy()) {
|
||||
if (event.key === "Escape" || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
@@ -1674,9 +1844,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",
|
||||
@@ -1696,7 +1866,6 @@ export class CanvasEditor {
|
||||
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");
|
||||
@@ -1736,6 +1905,16 @@ export class CanvasEditor {
|
||||
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 => {
|
||||
@@ -1743,7 +1922,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();
|
||||
@@ -1774,15 +1967,18 @@ export class CanvasEditor {
|
||||
}
|
||||
});
|
||||
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();
|
||||
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);
|
||||
@@ -1795,8 +1991,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);
|
||||
@@ -1809,20 +2005,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) {
|
||||
@@ -1846,35 +2056,38 @@ export class CanvasEditor {
|
||||
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, rich.resources);
|
||||
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 history = editing.history;
|
||||
const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }];
|
||||
for (const entry of values) {
|
||||
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.typing = format;
|
||||
session.caret = entry.caret || caret;
|
||||
session.record(before);
|
||||
}
|
||||
const before = session.model.runs;
|
||||
session.model.runs = normalizeRuns([{ text: value, format }]);
|
||||
session.caret = caret;
|
||||
session.record(before);
|
||||
if (rich.frame) {
|
||||
editing.frame = rich.frame;
|
||||
editing.item = { ...editing.item, richReflow: rich.reflow, textFrame: rich.frame };
|
||||
}
|
||||
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();
|
||||
@@ -2115,12 +2328,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();
|
||||
|
||||
+100
-23
@@ -1,17 +1,18 @@
|
||||
const DATABASE = "ofdgo";
|
||||
|
||||
export class FontPicker {
|
||||
constructor(input, toggle, list, onChange, onOpen) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false });
|
||||
constructor(input, toggle, list, onChange, onOpen, groupFonts) {
|
||||
Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false });
|
||||
input.addEventListener("input", () => this.show(input.value));
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("click", () => { if (!this.open) this.show(); });
|
||||
input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); });
|
||||
input.addEventListener("click", () => { if (!this.open) { input.focus(); input.select(); this.show(); } });
|
||||
input.addEventListener("keydown", event => this.keyDown(event));
|
||||
input.addEventListener("blur", () => { this.commitExact(); this.close(); });
|
||||
toggle.addEventListener("pointerdown", event => event.preventDefault());
|
||||
toggle.addEventListener("click", () => {
|
||||
if (this.open) this.close();
|
||||
else { input.focus(); this.show(); }
|
||||
else { input.focus(); input.select(); this.show(); }
|
||||
});
|
||||
list.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest(".font-option")) event.preventDefault();
|
||||
@@ -23,17 +24,40 @@ export class FontPicker {
|
||||
window.addEventListener("resize", () => this.close());
|
||||
}
|
||||
|
||||
setFonts(fonts, selected) {
|
||||
this.close();
|
||||
setFonts(fonts, selected, preserve = false) {
|
||||
const open = preserve && this.open;
|
||||
if (!open) this.close();
|
||||
this.fonts = fonts;
|
||||
this.groups = fonts.map((_, index) => [index]);
|
||||
this.names = new Map();
|
||||
for (const font of fonts) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
const key = font => font?.id || font?.postscriptName;
|
||||
const key = font => font?.richKey || font?.id || font?.postscriptName;
|
||||
this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected))));
|
||||
this.restore();
|
||||
if (open) {
|
||||
const active = this.matches[this.active]?.font;
|
||||
this.show(this.query, false);
|
||||
const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1;
|
||||
if (index >= 0) this.activate(index);
|
||||
} else this.restore();
|
||||
if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => {
|
||||
if (this.fonts !== fonts) return;
|
||||
this.groups = groups;
|
||||
this.names.clear();
|
||||
for (const group of groups) {
|
||||
const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||
}
|
||||
if (this.open) {
|
||||
const active = this.matches[this.active]?.index;
|
||||
this.show(this.query, false);
|
||||
const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled);
|
||||
if (index >= 0) this.activate(index);
|
||||
}
|
||||
else this.restore();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
@@ -43,23 +67,34 @@ export class FontPicker {
|
||||
|
||||
restore() {
|
||||
const font = this.fonts[Number(this.value)];
|
||||
this.input.value = font?.fullName || font?.name || "";
|
||||
const value = font?.menuFamily || font?.fullName || font?.name || "";
|
||||
if (this.input.value !== value) {
|
||||
this.input.value = value;
|
||||
if (document.activeElement === this.input) this.input.select();
|
||||
}
|
||||
this.input.scrollLeft = 0;
|
||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||
}
|
||||
|
||||
sourceLabel(font) {
|
||||
const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase();
|
||||
if (this.names.get(name) < 2) return "";
|
||||
return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统";
|
||||
}
|
||||
|
||||
show(query = "", load = true) {
|
||||
if (this.input.disabled) return;
|
||||
this.query = query;
|
||||
const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
||||
const terms = normalize(query).split(" ").filter(Boolean);
|
||||
this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => {
|
||||
const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" "));
|
||||
this.matches = this.groups.map(group => {
|
||||
const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0];
|
||||
return { font: this.fonts[index], index, group };
|
||||
}).filter(({ group }) => {
|
||||
const name = normalize(group.flatMap(index => {
|
||||
const font = this.fonts[index];
|
||||
return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])];
|
||||
}).filter(Boolean).join(" "));
|
||||
return terms.every(term => name.includes(term));
|
||||
});
|
||||
this.list.replaceChildren();
|
||||
@@ -70,7 +105,7 @@ export class FontPicker {
|
||||
option.dataset.index = String(index);
|
||||
option.setAttribute("data-disabled", String(Boolean(font.disabled)));
|
||||
const name = document.createElement("span");
|
||||
name.textContent = font.fullName || font.name;
|
||||
name.textContent = font.menuFamily || font.fullName || font.name;
|
||||
option.append(name);
|
||||
const source = this.sourceLabel(font);
|
||||
if (source) {
|
||||
@@ -91,7 +126,7 @@ export class FontPicker {
|
||||
if (!this.open) this.list.showPopover();
|
||||
this.open = true;
|
||||
this.position();
|
||||
const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value);
|
||||
const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value)));
|
||||
this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected);
|
||||
if (load) this.onOpen?.();
|
||||
}
|
||||
@@ -119,16 +154,27 @@ export class FontPicker {
|
||||
const changed = this.value !== String(index);
|
||||
this.value = String(index);
|
||||
this.close();
|
||||
if (changed) return this.onChange();
|
||||
if (changed) {
|
||||
const task = this.changing = Promise.resolve(this.onChange());
|
||||
return task.finally(() => { if (this.changing === task) this.changing = null; });
|
||||
}
|
||||
}
|
||||
|
||||
commitExact() {
|
||||
const value = this.input.value.trim().toLocaleLowerCase();
|
||||
const matches = font => font && !font.disabled
|
||||
&& [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
&& [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value);
|
||||
const selected = Number(this.value);
|
||||
const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches);
|
||||
if (index >= 0) this.choose(index);
|
||||
if (index >= 0) return this.choose(index);
|
||||
}
|
||||
|
||||
prepareCommit(event) {
|
||||
if (this.open && (!event || ![this.input, this.toggle, this.list].some(node => node.contains(event.target)))) {
|
||||
this.commitExact();
|
||||
this.close();
|
||||
}
|
||||
return this.changing;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -137,7 +183,7 @@ export class FontPicker {
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (event.isComposing) return;
|
||||
if (event.isComposing || event.key === "Tab" && (event.ctrlKey || event.metaKey || event.altKey)) return;
|
||||
if (!["Escape", "Enter", "ArrowDown", "ArrowUp", "Tab"].includes(event.key)) return;
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
@@ -314,13 +360,44 @@ export class FontManager {
|
||||
}), ...this.catalog];
|
||||
}
|
||||
|
||||
async loadFaces(inspect) {
|
||||
while (true) {
|
||||
async loadFaces(inspect, current = () => true) {
|
||||
while (current()) {
|
||||
const file = this.userFonts.find(font => font.enabled && !font.faces);
|
||||
if (!file) return;
|
||||
if (!file) break;
|
||||
try { file.faces = await inspect(await this.read(file)); }
|
||||
catch { file.faces = []; }
|
||||
catch { if (!current()) return; file.faces = []; }
|
||||
}
|
||||
const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed);
|
||||
await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => {
|
||||
while (current() && pending.length) {
|
||||
const font = pending.shift();
|
||||
try {
|
||||
const faces = await inspect(await this.read(font));
|
||||
const face = faces.find(face => face.postscriptName === font.postscriptName);
|
||||
if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names });
|
||||
else font.metadataFailed = true;
|
||||
} catch { if (current()) font.metadataFailed = true; }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async defaultFont(order, inspect, current = () => true) {
|
||||
const fonts = this.editorFonts().filter(font => !font.disabled);
|
||||
if (!fonts.length) return;
|
||||
const families = [["DengXian", "等线"], ["Microsoft YaHei", "微软雅黑"], ["PingFang SC", "苹方-简", "苹方 简体"],
|
||||
["Noto Sans SC", "Noto Sans CJK SC"], ["Source Han Sans SC", "Source Han Sans CN", "思源黑体", "思源黑体 CN"]];
|
||||
const indices = await order(fonts, families);
|
||||
let fallback = fonts.length;
|
||||
for (const index of indices) {
|
||||
if (!current()) return;
|
||||
try {
|
||||
await inspect(fonts[index], "中文汉字编辑水印,。!?");
|
||||
if (current()) return fonts[index];
|
||||
} catch (error) {
|
||||
if (error.missingGlyphs) fallback = Math.min(fallback, index);
|
||||
}
|
||||
}
|
||||
if (current()) return fonts[fallback];
|
||||
}
|
||||
|
||||
async add(fonts) {
|
||||
|
||||
@@ -292,6 +292,15 @@ export function createPageView(context) {
|
||||
unmountPage(index) {
|
||||
const shell = pageView.pageShell(index);
|
||||
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 range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (context.canvasEditor.input?.item.index === index || context.canvasEditor.crop?.item.index === index
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+197
-80
@@ -757,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)
|
||||
}
|
||||
@@ -3172,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 提取编辑时选定的字体,供输入预览和保存共用
|
||||
@@ -3401,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
|
||||
}
|
||||
@@ -4017,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
|
||||
}
|
||||
@@ -4111,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")
|
||||
@@ -4686,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
|
||||
@@ -4932,7 +4963,7 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) {
|
||||
}
|
||||
|
||||
// formatText 批量修改选区格式,未指定的属性保持各自原值
|
||||
// 入参: args 页面索引、完整路径数组和格式JSON
|
||||
// 入参: args 页面索引、完整路径数组、格式JSON及可选显式重排标志
|
||||
// 返回: any 文档信息, error 校验或提交错误
|
||||
func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
||||
return e.changeObjects(func() error {
|
||||
@@ -4954,23 +4985,90 @@ func (e *businessEngine) formatText(args []platformValue) (any, error) {
|
||||
}
|
||||
*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)
|
||||
@@ -4987,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
|
||||
@@ -5001,8 +5099,8 @@ 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")
|
||||
@@ -5011,10 +5109,19 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := e.currentEditor.TargetRichText(args[0].Int(), path)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -5061,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,
|
||||
@@ -5111,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])
|
||||
@@ -5125,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
|
||||
})
|
||||
@@ -5299,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 替换图片资源并更新预览
|
||||
@@ -5605,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 按指定轴等距分布选区
|
||||
// 入参: args 页面索引、对象标识数组和分布轴
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -7993,6 +8105,7 @@ 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,
|
||||
@@ -8026,11 +8139,13 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"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,
|
||||
@@ -8042,6 +8157,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,
|
||||
@@ -8057,6 +8173,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
|
||||
"ofdgoImportObjects": e.importObjects,
|
||||
"ofdgoOrderObjects": e.orderObjects,
|
||||
"ofdgoDistributeObjects": e.distributeObjects,
|
||||
"ofdgoDistributionAxes": e.distributionAxes,
|
||||
"ofdgoRotateObjects": e.rotateObjects,
|
||||
"ofdgoFlipObjects": e.flipObjects,
|
||||
"ofdgoCropImage": e.cropImage,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-8
@@ -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)
|
||||
@@ -801,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 使用编辑器字体来源和完整后端配置创建资源度量器
|
||||
|
||||
@@ -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 替换当前对象的会话记录并计入本次事务
|
||||
|
||||
@@ -338,7 +338,9 @@ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+198
-91
@@ -69,6 +69,7 @@ type richTextIndex struct {
|
||||
}
|
||||
|
||||
// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史
|
||||
// 按给定宽度排版,高度随内容自适应
|
||||
// 入参: page 页面索引, box 页面编辑框, value 富文本内容
|
||||
// 返回: string 组合对象标识, error 排版、资源或持久化错误
|
||||
func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) {
|
||||
@@ -96,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 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||
@@ -115,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
|
||||
}
|
||||
@@ -262,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
|
||||
@@ -338,19 +345,29 @@ 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)
|
||||
return e.updateCompositePlainRichText(page, path, index, value, offset, width, reflow)
|
||||
}
|
||||
var metadata richTextMetadata
|
||||
entryIndex := -1
|
||||
@@ -454,7 +471,7 @@ func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes [
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引
|
||||
// prepareRichText 构造独立组合资源和不含字体编号的编辑标引,高度随排版内容自适应
|
||||
// 入参: box 页面或局部编辑框, value 富文本
|
||||
// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误
|
||||
func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) {
|
||||
@@ -468,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")
|
||||
}
|
||||
@@ -492,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 = ""
|
||||
}
|
||||
@@ -677,9 +695,13 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
if origin := e.objectOrigin(object.TextObject.ID); layer.DrawParam != "" || origin != nil && !editorXMLSupported(origin.node) {
|
||||
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")}
|
||||
}
|
||||
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
return e.promoteTextValue(object.TextObject)
|
||||
}
|
||||
|
||||
@@ -693,11 +715,7 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
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()
|
||||
}
|
||||
frame, err := t.TextFrame()
|
||||
if err != nil {
|
||||
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()
|
||||
@@ -713,7 +731,7 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
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) {
|
||||
@@ -741,22 +759,20 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
}
|
||||
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)
|
||||
@@ -771,7 +787,11 @@ 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 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
|
||||
@@ -1064,63 +1084,48 @@ func richTextMetadataFontSafe(value string) bool {
|
||||
}
|
||||
|
||||
// formatCompositeText 修改内部富文本格式,普通文字安全提升后保留原实例变换
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, patch 格式增量, reflow 显式重排
|
||||
// 返回: error 识别、排版或提交错误
|
||||
func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch TextFormatPatch) error {
|
||||
value, err := e.TargetRichText(page, ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)})
|
||||
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)
|
||||
return e.updateCompositeRichText(page, path, index, value, 0, 0, reflow)
|
||||
}
|
||||
|
||||
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) 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, text := nodes[0], members[0].Object.TextObject
|
||||
node := nodes[0]
|
||||
if node.object.Type != "TextObject" || !editorXMLSupported(node.span) {
|
||||
return ErrNotRichText
|
||||
}
|
||||
text.CTM = ""
|
||||
_, err := e.promoteTextValue(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := text.TextFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dx, dy, err := e.richPlainOffset(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := node.object
|
||||
if !node.boundaryInCTM {
|
||||
object = compositeBoundary(object, node.parent, true)
|
||||
}
|
||||
box, err := creationBox(object.TextObject.Boundary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(dx+offset, dy))
|
||||
if width > 0 {
|
||||
frame.W = width
|
||||
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, matrix)
|
||||
unit.ID, unit.ResourceID = object.TextObject.ID, next.CompositeGraphicUnit.ResourceID
|
||||
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)
|
||||
@@ -1141,23 +1146,125 @@ func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index i
|
||||
// 入参: 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{}, ErrNotRichText
|
||||
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||
}
|
||||
return e.RichText(page, path.ID)
|
||||
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]
|
||||
member, err := e.CompositeObject(page, path, index)
|
||||
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 RichText{}, err
|
||||
return err
|
||||
}
|
||||
if member.Object.Type == "TextObject" {
|
||||
text := member.Object.TextObject
|
||||
text.CTM = ""
|
||||
return e.promoteTextValue(text)
|
||||
value.Layout, value.Paragraphs = options, nil
|
||||
if len(path.Children) == 0 {
|
||||
return e.updateRichTextFrame(page, path.ID, value, offset, width, true)
|
||||
}
|
||||
value, _, _, err := e.CompositeRichText(page, member)
|
||||
return value, err
|
||||
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)
|
||||
}
|
||||
+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})
|
||||
}
|
||||
|
||||
// DistributionAxes 获取选区可保持位置顺序的等间距分布方向,不修改文档或历史
|
||||
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径
|
||||
// 返回: []string 可用的horizontal和vertical方向, error 路径或度量错误
|
||||
func (e *Editor) DistributionAxes(page int, paths []ObjectPath) ([]string, error) {
|
||||
objects, annotations, groups, err := editorTargetGroups(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var boxes []Box
|
||||
var positions []editorObjectPosition
|
||||
if len(groups) == 0 {
|
||||
boxes, positions, _, err = e.selectionGeometry(page, objects, annotations)
|
||||
} else {
|
||||
boxes, _, _, err = e.targetGeometry(page, paths)
|
||||
positions = make([]editorObjectPosition, len(paths))
|
||||
for i := range positions {
|
||||
positions[i].index = i
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
axes := []string{}
|
||||
if len(boxes) < 3 {
|
||||
return axes, nil
|
||||
}
|
||||
for _, axis := range []string{"horizontal", "vertical"} {
|
||||
if _, err := editorDistribution(boxes, positions, axis); err == nil {
|
||||
axes = append(axes, axis)
|
||||
}
|
||||
}
|
||||
return axes, nil
|
||||
}
|
||||
|
||||
// ArrangeTargets 对齐或等距分布跨容器选区,单选对齐页面
|
||||
// 入参: page 页面索引, paths 按绘制顺序排列的完整路径, distribute 是否分布, direction 对齐方向或分布轴
|
||||
// 返回: error 路径、方向或编辑错误
|
||||
@@ -886,6 +920,150 @@ func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextForma
|
||||
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 校验或编辑错误
|
||||
@@ -925,14 +1103,14 @@ func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextForma
|
||||
plain = append(plain, id)
|
||||
continue
|
||||
}
|
||||
value, err := e.richTextObject(page, object)
|
||||
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.UpdateRichText(page, id, value); err != nil {
|
||||
if err := e.updateRichTextFrame(page, id, value, 0, 0, reflow); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -951,7 +1129,7 @@ func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextForma
|
||||
plain = append(plain, indexes[i])
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+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
@@ -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-- {
|
||||
|
||||
+134
-6
@@ -29,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,长度单位为毫米
|
||||
@@ -142,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 {
|
||||
@@ -189,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")
|
||||
}
|
||||
@@ -232,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
|
||||
@@ -275,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
|
||||
@@ -310,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 应用显式给出的格式字段
|
||||
|
||||
@@ -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