feat(编辑能力): 增强图文编辑能力,优化界面与保存可靠性
build.yaml / build (push) Successful in 42s

This commit is contained in:
xiaoqidun committed 2026-09-24 11:58:40 +08:00
1 parent 0508029387
commit d7cd3d03b5
20 files changed
+1135 -114

No files matched your search

+26
View File
@@ -184,6 +184,31 @@
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div> <div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button" aria-label="复制格式">复制</button><button id="pasteStyleButton" class="button" type="button" aria-label="粘贴格式">粘贴</button></div></div> <div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button" aria-label="复制格式">复制</button><button id="pasteStyleButton" class="button" type="button" aria-label="粘贴格式">粘贴</button></div></div>
<div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div> <div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div>
<details id="objectGradientFields" hidden>
<summary>渐变</summary>
<div class="form-pair">
<div class="form-row"><label for="gradientTarget">作用</label><select id="gradientTarget"><option value="fill">填充</option><option value="stroke">描边</option></select></div>
<div class="form-row"><label for="gradientKind">类型</label><select id="gradientKind"><option value="keep">保持</option><option value="linear">线性</option><option value="radial">径向</option></select></div>
</div>
<div id="gradientControls" hidden>
<div class="form-row"><label for="gradientAngle">角度</label><div class="form-unit"><input id="gradientAngle" type="number" step="any" value="0"><span>°</span></div></div>
<div class="gradient-stop-row gradient-stop-head"><span>颜色</span><span>位置%</span><span>透明%</span><span></span></div>
<div id="gradientStops"></div>
<button id="gradientAdd" class="small-button" type="button" title="添加色标" aria-label="添加色标">+</button>
</div>
</details>
<details id="objectPatternFields" hidden>
<summary>图案</summary>
<div class="form-pair">
<div class="form-row"><label for="patternWidth">宽度</label><div class="form-unit"><input id="patternWidth" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="patternHeight">高度</label><div class="form-unit"><input id="patternHeight" type="number" min="0" step="any"><span>mm</span></div></div>
</div>
<div class="form-pair">
<div class="form-row"><label for="patternXStep">横距</label><div class="form-unit"><input id="patternXStep" type="number" min="0" step="any"><span>mm</span></div></div>
<div class="form-row"><label for="patternYStep">纵距</label><div class="form-unit"><input id="patternYStep" type="number" min="0" step="any"><span>mm</span></div></div>
</div>
<div class="form-row"><label for="patternCTM">矩阵</label><input id="patternCTM" type="text" spellcheck="false" autocomplete="off"></div>
</details>
<div id="objectStrokeFields"> <div id="objectStrokeFields">
<div class="form-row"><label for="objectDash">线型</label><select id="objectDash"><option value="mixed" hidden>混合</option><option value="solid">实线</option><option value="dash">虚线</option><option value="dot">点线</option><option value="dashdot">点划</option><option value="custom">自定</option></select></div> <div class="form-row"><label for="objectDash">线型</label><select id="objectDash"><option value="mixed" hidden>混合</option><option value="solid">实线</option><option value="dash">虚线</option><option value="dot">点线</option><option value="dashdot">点划</option><option value="custom">自定</option></select></div>
<div id="objectDashRow" class="form-row" hidden><label for="objectDashPattern">间隔</label><div class="form-unit"><input id="objectDashPattern" type="text" autocomplete="off" spellcheck="false"><span>mm</span></div></div> <div id="objectDashRow" class="form-row" hidden><label for="objectDashPattern">间隔</label><div class="form-unit"><input id="objectDashPattern" type="text" autocomplete="off" spellcheck="false"><span>mm</span></div></div>
@@ -267,6 +292,7 @@
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label> <label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label> <label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label>
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label> <label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label>
<details class="info-custom"><summary>扩展</summary><div id="infoCustomRows"></div><button id="infoCustomAdd" class="small-button" type="button" title="添加字段" aria-label="添加字段">+</button></details>
<div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div> <div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form> </form>
</dialog> </dialog>
+52
View File
@@ -774,6 +774,8 @@ button:not(:disabled):hover {
outline-offset: 2px; outline-offset: 2px;
} }
.info-custom-row input,
.gradient-stop-row input,
.form-row input[type="text"], .form-row input[type="text"],
.form-row input[type="password"], .form-row input[type="password"],
.form-row input[type="number"], .form-row input[type="number"],
@@ -798,6 +800,56 @@ button:not(:disabled):hover {
resize: vertical; resize: vertical;
} }
.form-dialog details {
margin-bottom: 12px;
}
.form-dialog details > summary {
cursor: pointer;
}
.form-dialog details[open] > summary {
margin-bottom: 12px;
}
#infoCustomRows {
max-height: 240px;
overflow-y: auto;
}
.info-custom-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr) var(--control-height);
gap: 6px;
margin: 6px 0;
}
#infoCustomRows > :first-child {
margin-top: 0;
}
.gradient-stop-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr) var(--control-height);
gap: 6px;
margin: 6px 0;
align-items: center;
}
.gradient-stop-head {
font-size: 12px;
}
#gradientStops {
max-height: 180px;
overflow-y: auto;
}
#infoCustomRows :focus-visible,
#gradientStops :focus-visible {
outline-offset: -2px;
}
.color-input { .color-input {
width: calc(2em + 22px); width: calc(2em + 22px);
height: var(--control-height); height: var(--control-height);
+113 -3
View File
@@ -213,6 +213,8 @@ const el = {
infoPanel: document.querySelector("#infoPanel"), infoPanel: document.querySelector("#infoPanel"),
infoForm: document.querySelector("#infoForm"), infoForm: document.querySelector("#infoForm"),
infoTitle: document.querySelector("#infoTitle"), infoTitle: document.querySelector("#infoTitle"),
infoCustomRows: document.querySelector("#infoCustomRows"),
infoCustomAdd: document.querySelector("#infoCustomAdd"),
infoAuthor: document.querySelector("#infoAuthor"), infoAuthor: document.querySelector("#infoAuthor"),
infoSubject: document.querySelector("#infoSubject"), infoSubject: document.querySelector("#infoSubject"),
infoCancel: document.querySelector("#infoCancel"), infoCancel: document.querySelector("#infoCancel"),
@@ -286,6 +288,19 @@ const el = {
groupObjectsButton: document.querySelector("#groupObjectsButton"), groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"), ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
objectOpacity: document.querySelector("#objectOpacity"), objectOpacity: document.querySelector("#objectOpacity"),
objectGradientFields: document.querySelector("#objectGradientFields"),
gradientTarget: document.querySelector("#gradientTarget"),
gradientKind: document.querySelector("#gradientKind"),
gradientControls: document.querySelector("#gradientControls"),
gradientAngle: document.querySelector("#gradientAngle"),
gradientStops: document.querySelector("#gradientStops"),
gradientAdd: document.querySelector("#gradientAdd"),
objectPatternFields: document.querySelector("#objectPatternFields"),
patternWidth: document.querySelector("#patternWidth"),
patternHeight: document.querySelector("#patternHeight"),
patternXStep: document.querySelector("#patternXStep"),
patternYStep: document.querySelector("#patternYStep"),
patternCTM: document.querySelector("#patternCTM"),
objectStrokeFields: document.querySelector("#objectStrokeFields"), objectStrokeFields: document.querySelector("#objectStrokeFields"),
objectDash: document.querySelector("#objectDash"), objectDash: document.querySelector("#objectDash"),
objectDashRow: document.querySelector("#objectDashRow"), objectDashRow: document.querySelector("#objectDashRow"),
@@ -590,14 +605,41 @@ editorClick(el.paragraphButton, () => {
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
editorClick(el.infoButton, () => { editorClick(el.infoButton, () => {
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || ""; for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
el.infoCustomRows.replaceChildren();
for (const [index, field] of (state.doc.customData || []).entries()) addCustomDataRow(field, index);
el.infoStatus.textContent = ""; el.infoStatus.textContent = "";
el.infoPanel.showModal(); el.infoPanel.showModal();
}); });
el.infoCancel.addEventListener("click", () => el.infoPanel.close()); el.infoCancel.addEventListener("click", () => el.infoPanel.close());
el.infoCustomAdd.addEventListener("click", () => addCustomDataRow());
el.infoForm.addEventListener("submit", async event => { el.infoForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close(); const fields = [...el.infoCustomRows.children].map(row => ({Name: row.children[0].value, Value: row.children[1].value, Source: row.sourceIndex}));
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value, JSON.stringify(fields))) el.infoPanel.close();
}); });
function addCustomDataRow(field = {Name: "", Value: ""}, sourceIndex) {
const row = document.createElement("div");
row.sourceIndex = sourceIndex;
row.className = "info-custom-row";
for (const [key, label] of [["Name", "名称"], ["Value", "内容"]]) {
const input = document.createElement("input");
input.type = "text";
input.value = field[key];
input.setAttribute("aria-label", label);
input.placeholder = label;
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除字段";
remove.setAttribute("aria-label", "删除字段");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.infoCustomRows.append(row);
}
el.annotationClose.addEventListener("click", () => el.annotationNote.close()); el.annotationClose.addEventListener("click", () => el.annotationNote.close());
el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; }); el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; });
el.penTool.addEventListener("change", () => { el.penTool.addEventListener("change", () => {
@@ -947,12 +989,22 @@ el.objectBoundsForm.addEventListener("submit", async event => {
if (saved) el.objectBoundsPanel.close(); if (saved) el.objectBoundsPanel.close();
}); });
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close()); el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.gradientKind.addEventListener("change", () => {
el.gradientControls.hidden = el.gradientKind.value === "keep";
el.gradientAngle.disabled = el.gradientKind.value !== "linear";
for (const input of el.gradientStops.querySelectorAll("input")) input.disabled = el.gradientKind.value === "keep";
});
el.gradientAdd.addEventListener("click", () => addGradientStop(50, "#808080", 0));
el.objectDash.addEventListener("change", () => { el.objectDash.addEventListener("change", () => {
el.objectDashRow.hidden = el.objectDash.value !== "custom"; el.objectDashRow.hidden = el.objectDash.value !== "custom";
el.objectDashPattern.required = !el.objectDashRow.hidden; el.objectDashPattern.required = !el.objectDashRow.hidden;
}); });
el.objectStyleForm.addEventListener("submit", async event => { el.objectStyleForm.addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep" && el.gradientStops.children.length < 2) {
el.objectStyleStatus.textContent = "至少保留两个色标";
return;
}
const style = readObjectStyle(); const style = readObjectStyle();
const item = canvasEditor.selected; const item = canvasEditor.selected;
if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close(); if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close();
@@ -1390,6 +1442,21 @@ function openObjectStyle() {
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]); el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item)); el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
const pattern = items.length === 1 && !items[0].scoped ? items[0].fillPattern : null;
el.objectGradientFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "PathObject" || !items[0].paintSize || !canEditObject(items[0], "paint");
el.gradientKind.value = "keep";
el.gradientTarget.value = "fill";
el.gradientControls.hidden = true;
el.gradientAngle.value = "0";
el.gradientAngle.disabled = true;
el.gradientStops.replaceChildren();
addGradientStop(0, "#000000", 0);
addGradientStop(100, "#ffffff", 0);
el.objectPatternFields.hidden = !pattern;
for (const [input, key] of [[el.patternWidth, "width"], [el.patternHeight, "height"], [el.patternXStep, "xStep"], [el.patternYStep, "yStep"], [el.patternCTM, "ctm"]]) {
input.value = pattern?.[key] ?? "";
input.disabled = !pattern;
}
const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", ""); const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", "");
el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100);
el.objectOpacity.placeholder = alpha === null ? "混合" : ""; el.objectOpacity.placeholder = alpha === null ? "混合" : "";
@@ -1401,13 +1468,32 @@ function openObjectStyle() {
el.objectDashOffset.value = shared("dashOffset", 0) ?? ""; el.objectDashOffset.value = shared("dashOffset", 0) ?? "";
el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt"); el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt");
el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter"); el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter");
state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value])); state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin", "patternWidth", "patternHeight", "patternXStep", "patternYStep", "patternCTM"].map(key => [key, el[key].value]));
el.objectStyleStatus.textContent = ""; el.objectStyleStatus.textContent = "";
el.objectStylePanel.showModal(); el.objectStylePanel.showModal();
} }
function readObjectStyle() { function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key]; const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
if (!el.objectGradientFields.hidden && el.gradientKind.value !== "keep") {
const [width, height] = canvasEditor.selected.paintSize;
const Segment = [...el.gradientStops.children].map(row => ({Position: Number(row.children[1].value) / 100, Color: {Value: row.children[0].value, Alpha: Math.round(255 * (1 - Number(row.children[2].value) / 100))}})).sort((a, b) => a.Position - b.Position);
const angle = Number(el.gradientAngle.value) * Math.PI / 180, x = Math.cos(angle), y = Math.sin(angle);
const length = Math.abs(width * x) + Math.abs(height * y);
const paint = el.gradientKind.value === "linear"
? {AxialShd: {StartPoint: `${width / 2 - x * length / 2} ${height / 2 - y * length / 2}`, EndPoint: `${width / 2 + x * length / 2} ${height / 2 + y * length / 2}`, Extend: "3", Segment}}
: {RadialShd: {StartPoint: `${width / 2} ${height / 2}`, EndPoint: `${width / 2} ${height / 2}`, EndRadius: Math.max(width, height) / 2, Extend: "3", Segment}};
const target = el.gradientTarget.value;
style[`${target}Paint`] = JSON.stringify(paint);
style[target] = true;
}
if (!el.objectPatternFields.hidden) {
const pattern = {};
for (const [input, key] of [["patternWidth", "Width"], ["patternHeight", "Height"], ["patternXStep", "XStep"], ["patternYStep", "YStep"], ["patternCTM", "CTM"]]) {
if (changed(input)) pattern[key] = key === "CTM" ? el[input].value.trim() : Number(el[input].value);
}
if (Object.keys(pattern).length) style.patternStyle = JSON.stringify(pattern);
}
if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255); if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255);
if (!el.objectStrokeFields.hidden) { if (!el.objectStrokeFields.hidden) {
if (changed("objectDash") || changed("objectDashPattern")) { if (changed("objectDash") || changed("objectDashPattern")) {
@@ -1421,6 +1507,30 @@ function readObjectStyle() {
return style; return style;
} }
function addGradientStop(position, color, transparency) {
const row = document.createElement("div");
row.className = "gradient-stop-row";
for (const [type, value, label] of [["color", color, "色标颜色"], ["number", position, "色标位置"], ["number", transparency, "色标透明度"]]) {
const input = document.createElement("input");
input.type = type;
input.value = value;
input.disabled = el.gradientKind.value === "keep";
input.setAttribute("aria-label", label);
input.title = label;
if (type === "number") { input.min = "0"; input.max = "100"; input.step = "any"; input.required = true; }
row.append(input);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "small-button";
remove.textContent = "×";
remove.title = "删除色标";
remove.setAttribute("aria-label", "删除色标");
remove.addEventListener("click", () => row.remove());
row.append(remove);
el.gradientStops.append(row);
}
function openOutlinePanel(action) { function openOutlinePanel(action) {
state.outlineAction = action; state.outlineAction = action;
let outline = null, items = state.doc.outlines || []; let outline = null, items = state.doc.outlines || [];
@@ -2439,7 +2549,7 @@ async function changeDocument(name, item, ...args) {
} }
setEditorInfo(doc); setEditorInfo(doc);
if (name === "ofdgoUpdateInfo") { if (name === "ofdgoUpdateInfo") {
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject }); Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData });
renderMeta(); renderMeta();
updateControls(); updateControls();
rememberEditorView(before, revision); rememberEditorView(before, revision);
+105 -4
View File
@@ -1712,8 +1712,10 @@ func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[st
// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率 // 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率
func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) { func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) {
var alpha *int var alpha *int
var fill *ofdgo.FillColor
switch object.Type { switch object.Type {
case "TextObject": case "TextObject":
fill = object.TextObject.FillColor
alpha = object.TextObject.Alpha alpha = object.TextObject.Alpha
if paint { if paint {
item["color"] = editorColorHex(object.TextObject.FillColor) item["color"] = editorColorHex(object.TextObject.FillColor)
@@ -1723,6 +1725,10 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
item["imageBorder"] = object.ImageObject.Border != nil item["imageBorder"] = object.ImageObject.Border != nil
case "PathObject": case "PathObject":
path := object.PathObject path := object.PathObject
fill = path.FillColor
if box, err := ofdgo.ParseBox(path.Boundary); err == nil {
item["paintSize"] = []any{box.W, box.H}
}
alpha = path.Alpha alpha = path.Alpha
item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0 item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0
if path.DashOffset != nil { if path.DashOffset != nil {
@@ -1740,6 +1746,10 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo
if alpha != nil { if alpha != nil {
item["alpha"] = *alpha item["alpha"] = *alpha
} }
if fill != nil && fill.Pattern != nil {
p := fill.Pattern
item["fillPattern"] = map[string]any{"width": p.Width, "height": p.Height, "xStep": p.XStep, "yStep": p.YStep, "ctm": p.CTM}
}
} }
// compositePath 解析画布复合对象路径 // compositePath 解析画布复合对象路径
@@ -2889,13 +2899,70 @@ func changeOutline(args []js.Value) (any, error) {
return editorOutlineInfo{info.(editorInfo), path}, nil return editorOutlineInfo{info.(editorInfo), path}, nil
} }
// styleObjects 原子更新对象透明度和路径描边属性 // styleObjects 原子更新对象外观和图案布局
// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值 // 入参: args 页面索引、对象标识数组及样式,省略字段保持原值
// 返回: any 文档信息, error 错误信息 // 返回: any 文档信息, error 错误信息
func styleObjects(args []js.Value) (any, error) { func styleObjects(args []js.Value) (any, error) {
style := objectStyle(args[2])
var pattern *ofdgo.PatternStyle
if field := args[2].Get("patternStyle"); !field.IsUndefined() {
if err := json.Unmarshal([]byte(field.String()), &pattern); err != nil {
return nil, err
}
}
if field := args[2].Get("fillPaint"); !field.IsUndefined() {
if err := json.Unmarshal([]byte(field.String()), &style.FillColor); err != nil {
return nil, err
}
}
if field := args[2].Get("strokePaint"); !field.IsUndefined() {
if err := json.Unmarshal([]byte(field.String()), &style.StrokeColor); err != nil {
return nil, err
}
}
return changeObjects(func() error { return changeObjects(func() error {
return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), objectStyle(args[2])) return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
page, ids := args[0].Int(), stringsFromJS(args[1])
for _, paint := range []*ofdgo.FillColor{style.FillColor, (*ofdgo.FillColor)(style.StrokeColor)} {
if err := resolveStylePaint(paint); err != nil {
return err
}
}
if err := editor.StyleObjects(page, ids, style); err != nil {
return err
}
if pattern != nil {
return editor.StylePatterns(page, ids, false, *pattern)
}
return nil
}) })
})
}
// resolveStylePaint 将渐变色标中的浏览器色值转换为文档颜色空间
// 入参: paint 颜色数据
// 返回: error 错误信息
func resolveStylePaint(paint *ofdgo.FillColor) error {
if paint == nil {
return nil
}
var segments []ofdgo.ShdSegment
if paint.AxialShd != nil {
segments = paint.AxialShd.Segment
} else if paint.RadialShd != nil {
segments = paint.RadialShd.Segment
}
for i := range segments {
stop := &segments[i].Color
if strings.HasPrefix(stop.Value, "#") {
color := ofdgo.FillColor{Alpha: stop.Alpha}
if err := setEditorColor(&color, stop.Value); err != nil {
return err
}
*stop = ofdgo.ShdColor{Value: color.Value, ColorSpace: color.ColorSpace, Alpha: color.Alpha}
}
}
return nil
} }
// objectStyle 读取高级面板提交的外观字段,省略字段保持原值 // objectStyle 读取高级面板提交的外观字段,省略字段保持原值
@@ -2903,6 +2970,14 @@ func styleObjects(args []js.Value) (any, error) {
// 返回: ofdgo.ObjectStyle 库层样式 // 返回: ofdgo.ObjectStyle 库层样式
func objectStyle(value js.Value) ofdgo.ObjectStyle { func objectStyle(value js.Value) ofdgo.ObjectStyle {
style := ofdgo.ObjectStyle{} style := ofdgo.ObjectStyle{}
if field := value.Get("fill"); !field.IsUndefined() {
v := field.Bool()
style.Fill = &v
}
if field := value.Get("stroke"); !field.IsUndefined() {
v := field.Bool()
style.Stroke = &v
}
if field := value.Get("alpha"); !field.IsUndefined() { if field := value.Get("alpha"); !field.IsUndefined() {
v := field.Int() v := field.Int()
style.Alpha = &v style.Alpha = &v
@@ -3026,13 +3101,39 @@ func createDocument(args []js.Value) (any, error) {
return previewEditor(editor, args[3].Bool()) return previewEditor(editor, args[3].Bool())
} }
// updateInfo 修改标题、作者和主题,保留其他元数据及创建程序标识 // updateInfo 修改文档信息和自定义字段,保留其他元数据及创建程序标识
// 入参: args 标题、作者、主题 // 入参: args 标题、作者、主题、可选自定义字段JSON
// 返回: any 编辑状态, error 错误信息 // 返回: any 编辑状态, error 错误信息
func updateInfo(args []js.Value) (any, error) { func updateInfo(args []js.Value) (any, error) {
return changeObjects(func() error { return changeObjects(func() error {
info := currentEditor.Info info := currentEditor.Info
info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String() info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String()
if len(args) > 3 {
var submitted []struct {
ofdgo.CustomData
Source *int
}
if err := json.Unmarshal([]byte(args[3].String()), &submitted); err != nil {
return err
}
var previous []ofdgo.CustomData
if info.CustomDatas != nil {
previous = info.CustomDatas.CustomData
}
fields := make([]ofdgo.CustomData, len(submitted))
for i, field := range submitted {
if field.Source != nil {
if *field.Source < 0 || *field.Source >= len(previous) {
return fmt.Errorf("invalid custom data source")
}
fields[i] = previous[*field.Source]
}
fields[i].Name, fields[i].Value = field.Name, field.Value
}
if !slices.Equal(previous, fields) {
info.CustomDatas = &ofdgo.CustomDatas{CustomData: fields}
}
}
currentEditor.SetInfo(info) currentEditor.SetInfo(info)
return nil return nil
}) })
+7
View File
@@ -73,6 +73,7 @@ type DocumentInfo struct {
Title string `json:"title"` Title string `json:"title"`
Author string `json:"author"` Author string `json:"author"`
Subject string `json:"subject"` Subject string `json:"subject"`
CustomData []ofdgo.CustomData `json:"customData,omitempty"`
CreationDate string `json:"creationDate"` CreationDate string `json:"creationDate"`
ModDate string `json:"modDate"` ModDate string `json:"modDate"`
PageCount int `json:"pageCount"` PageCount int `json:"pageCount"`
@@ -450,6 +451,9 @@ func (s *Session) Summary() DocumentInfo {
info.Title = docInfo.Title info.Title = docInfo.Title
info.Author = docInfo.Author info.Author = docInfo.Author
info.Subject = docInfo.Subject info.Subject = docInfo.Subject
if docInfo.CustomDatas != nil {
info.CustomData = append([]ofdgo.CustomData(nil), docInfo.CustomDatas.CustomData...)
}
info.CreationDate = docInfo.CreationDate info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate info.ModDate = docInfo.ModDate
} }
@@ -523,6 +527,9 @@ func (s *Session) Info() DocumentInfo {
info.Title = docInfo.Title info.Title = docInfo.Title
info.Author = docInfo.Author info.Author = docInfo.Author
info.Subject = docInfo.Subject info.Subject = docInfo.Subject
if docInfo.CustomDatas != nil {
info.CustomData = append([]ofdgo.CustomData(nil), docInfo.CustomDatas.CustomData...)
}
info.CreationDate = docInfo.CreationDate info.CreationDate = docInfo.CreationDate
info.ModDate = docInfo.ModDate info.ModDate = docInfo.ModDate
} }
+10 -3
View File
@@ -783,7 +783,9 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
} }
} }
if obj.Border != nil { if obj.Border != nil {
return GraphicObject{}, fmt.Errorf("image borders are not supported for creation") if err := e.validateImageBorder(obj.Border); err != nil {
return GraphicObject{}, err
}
} }
obj.ID = id obj.ID = id
boundary, ctm = obj.Boundary, obj.CTM boundary, ctm = obj.Boundary, obj.CTM
@@ -802,8 +804,13 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
} else if object.Type == "ImageObject" { } else if object.Type == "ImageObject" {
object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H)) object.ImageObject.CTM = fmt.Sprintf("%s 0 0 %s 0 0", ofdNumber(box.W), ofdNumber(box.H))
} }
if drawParam != "" || len(actions) != 0 { if drawParam != "" {
return GraphicObject{}, fmt.Errorf("draw parameter references and actions are not supported for creation") if _, err := e.editorDrawParam(drawParam, make(map[string]bool)); err != nil {
return GraphicObject{}, err
}
}
if err := validateObjectActions(actions); err != nil {
return GraphicObject{}, err
} }
if err := e.validateObjectClips(clips); err != nil { if err := e.validateObjectClips(clips); err != nil {
return GraphicObject{}, err return GraphicObject{}, err
+194 -3
View File
@@ -23,6 +23,70 @@ import (
"strings" "strings"
) )
// PatternStyle 图案尺寸、步进和变换增量,nil字段保持原值,单位为毫米
type PatternStyle struct {
Width *float64
Height *float64
XStep *float64
YStep *float64
CTM *string
}
// StylePatterns 修改已有图案布局,保留单元内容、资源引用和未知扩展
// 入参: page 页面索引, ids 对象标识, stroke 是否修改描边, style 图案布局增量
// 返回: error 错误信息
func (e *Editor) StylePatterns(page int, ids []string, stroke bool, style PatternStyle) error {
for _, size := range []*float64{style.Width, style.Height} {
if size != nil && (!finite(*size) || *size <= 0) {
return fmt.Errorf("pattern dimensions must be positive and finite")
}
}
for _, step := range []*float64{style.XStep, style.YStep} {
if step != nil && (!finite(*step) || *step < 0) {
return fmt.Errorf("pattern steps must be nonnegative and finite")
}
}
if style.CTM != nil && *style.CTM != "" {
if _, err := creationNumbers(*style.CTM, 6); err != nil {
return err
}
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
for i := range objects {
objects[i] = cloneEditorData(objects[i])
object := &objects[i]
var fill *FillColor
switch object.Type {
case "TextObject":
fill = object.TextObject.FillColor
if stroke {
fill = (*FillColor)(object.TextObject.StrokeColor)
}
case "PathObject", "Path":
fill = object.PathObject.FillColor
if stroke {
fill = (*FillColor)(object.PathObject.StrokeColor)
}
}
if fill == nil || fill.Pattern == nil {
return fmt.Errorf("object %q does not use a pattern", ids[i])
}
pattern := fill.Pattern
for _, pair := range []struct{ value, target *float64 }{{style.Width, &pattern.Width}, {style.Height, &pattern.Height}, {style.XStep, &pattern.XStep}, {style.YStep, &pattern.YStep}} {
if pair.value != nil {
*pair.target = *pair.value
}
}
if style.CTM != nil {
pattern.CTM = *style.CTM
}
}
return e.updateObjects(page, objects, true)
}
// Color 解析当前文档的纯色,支持RGB、灰度、CMYK及调色板,不修改原颜色 // Color 解析当前文档的纯色,支持RGB、灰度、CMYK及调色板,不修改原颜色
// 入参: value 颜色,nil表示默认黑色 // 入参: value 颜色,nil表示默认黑色
// 返回: color.NRGBA 非预乘颜色, error 错误信息 // 返回: color.NRGBA 非预乘颜色, error 错误信息
@@ -141,9 +205,42 @@ func (e *Editor) RGBColor(value color.NRGBA) (*FillColor, error) {
return result, nil return result, nil
} }
// PaintObjects 更新文字或路径的填充或描边,保留定位、其他样式和原文扩展
// 入参: page 页面索引, ids 对象标识, paint 绘制颜色,nil恢复默认, stroke 是否修改描边
// 返回: error 错误信息
func (e *Editor) PaintObjects(page int, ids []string, paint *FillColor, stroke bool) error {
if err := e.editorColor(paint); err != nil {
return err
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
for i := range objects {
object := &objects[i]
switch object.Type {
case "TextObject":
if stroke {
object.TextObject.StrokeColor = (*StrokeColor)(cloneEditorData(paint))
} else {
object.TextObject.FillColor = cloneEditorData(paint)
}
case "PathObject":
if stroke {
object.PathObject.StrokeColor = (*StrokeColor)(cloneEditorData(paint))
} else {
object.PathObject.FillColor = cloneEditorData(paint)
}
default:
return fmt.Errorf("object %q does not have text or path paint", editorObjectID(*object))
}
}
return e.updateObjects(page, objects, true)
}
// editorPaintable 判断文字与路径颜色能否在保留其他字段的前提下修改 // editorPaintable 判断文字与路径颜色能否在保留其他字段的前提下修改
// 入参: object 图形对象 // 入参: object 图形对象
// 返回: bool 是否支持纯色编辑 // 返回: bool 是否支持绘制颜色编辑
func (e *Editor) editorPaintable(object GraphicObject) bool { func (e *Editor) editorPaintable(object GraphicObject) bool {
var fill, stroke *FillColor var fill, stroke *FillColor
switch object.Type { switch object.Type {
@@ -155,17 +252,111 @@ func (e *Editor) editorPaintable(object GraphicObject) bool {
return false return false
} }
for _, value := range []*FillColor{fill, stroke} { for _, value := range []*FillColor{fill, stroke} {
if _, err := e.Color(value); err != nil { if err := e.editorColor(value); err != nil {
return false return false
} }
} }
return true return true
} }
// editorColor 校验当前文档的颜色空间和分量 // collectPaintReferences 收集纯色、渐变和图案引用的资源
// 入参: paint 绘制颜色, used 引用集合
func collectPaintReferences(paint *FillColor, used map[string]bool) {
if paint == nil {
return
}
used[paint.ColorSpace] = true
var segments []ShdSegment
if paint.AxialShd != nil {
segments = paint.AxialShd.Segment
}
if paint.RadialShd != nil {
segments = paint.RadialShd.Segment
}
for _, segment := range segments {
used[segment.Color.ColorSpace] = true
}
if paint.Pattern != nil {
for _, object := range paint.Pattern.CellContent.Objects {
collectObjectReferences(object, used)
}
}
}
// collectObjectReferences 收集对象和嵌套绘制内容引用的资源
// 入参: object 图形对象, used 引用集合
func collectObjectReferences(object GraphicObject, used map[string]bool) {
switch object.Type {
case "TextObject":
used[object.TextObject.Font], used[object.TextObject.DrawParam] = true, true
collectPaintReferences(object.TextObject.FillColor, used)
collectPaintReferences((*FillColor)(object.TextObject.StrokeColor), used)
case "PathObject", "Path":
used[object.PathObject.DrawParam] = true
collectPaintReferences(object.PathObject.FillColor, used)
collectPaintReferences((*FillColor)(object.PathObject.StrokeColor), used)
case "ImageObject":
used[object.ImageObject.ResourceID], used[object.ImageObject.ImageMask] = true, true
if object.ImageObject.Border != nil {
collectPaintReferences((*FillColor)(object.ImageObject.Border.BorderColor), used)
}
case "CompositeObject", "CompositeGraphicUnit":
collectCompositeReferences(object.CompositeGraphicUnit, used)
}
}
// editorColor 校验当前文档的纯色和渐变
// 入参: value 颜色 // 入参: value 颜色
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) editorColor(value *FillColor) error { func (e *Editor) editorColor(value *FillColor) error {
if value == nil || value.AxialShd == nil && value.RadialShd == nil {
_, err := e.Color(value) _, err := e.Color(value)
return err return err
}
if value.unsupported || value.Pattern != nil || value.AxialShd != nil && value.RadialShd != nil || value.Value != "" || value.Index != nil {
return fmt.Errorf("specify one paint source")
}
if value.Alpha != nil && (*value.Alpha < 0 || *value.Alpha > 255) {
return fmt.Errorf("color alpha must be between 0 and 255")
}
var mapType, extend, start, end string
var unit float64
var segments []ShdSegment
if node := value.AxialShd; node != nil {
mapType, unit, extend, start, end, segments = node.MapType, node.MapUnit, node.Extend, node.StartPoint, node.EndPoint, node.Segment
} else {
node := value.RadialShd
mapType, unit, extend, start, end, segments = node.MapType, node.MapUnit, node.Extend, node.StartPoint, node.EndPoint, node.Segment
if !finite(node.StartRadius) || !finite(node.EndRadius) || node.StartRadius < 0 || node.EndRadius < 0 || !finite(node.Eccentricity) || node.Eccentricity < 0 || node.Eccentricity >= 1 || !finite(node.Angle) {
return fmt.Errorf("invalid radial gradient geometry")
}
}
if !slices.Contains([]string{"", "Direct", "Repeat", "Reflect"}, mapType) || !finite(unit) || unit < 0 || (mapType == "Repeat" || mapType == "Reflect") && unit == 0 || !slices.Contains([]string{"", "0", "1", "2", "3"}, extend) {
return fmt.Errorf("invalid gradient mapping")
}
for _, point := range []string{start, end} {
if _, err := creationNumbers(point, 2); err != nil {
return err
}
}
if len(segments) < 2 {
return fmt.Errorf("gradient requires at least two stops")
}
previous := -1.0
positions := gradientPositions(segments)
for index, segment := range segments {
position := positions[index]
if !finite(position) || position < previous || position < 0 || position > 1 {
return fmt.Errorf("gradient stops must be ordered within zero and one")
}
previous = position
color := segment.Color
if color.ColorSpace == "" {
color.ColorSpace = value.ColorSpace
}
if _, err := e.Color(&FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha}); err != nil {
return err
}
}
return nil
} }
+1 -20
View File
@@ -1068,25 +1068,6 @@ func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]
used[composite.ResourceID] = true used[composite.ResourceID] = true
used[composite.DrawParam] = true used[composite.DrawParam] = true
for _, object := range composite.Objects { for _, object := range composite.Objects {
var fill, stroke *FillColor collectObjectReferences(object, used)
switch object.Type {
case "TextObject":
used[object.TextObject.Font] = true
used[object.TextObject.DrawParam] = true
fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor)
case "PathObject":
used[object.PathObject.DrawParam] = true
fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)
case "ImageObject":
used[object.ImageObject.ResourceID] = true
used[object.ImageObject.ImageMask] = true
case "CompositeObject", "CompositeGraphicUnit":
collectCompositeReferences(object.CompositeGraphicUnit, used)
}
for _, color := range []*FillColor{fill, stroke} {
if color != nil {
used[color.ColorSpace] = true
}
}
} }
} }
+12 -6
View File
@@ -93,8 +93,10 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra
return err return err
} }
if node.object.Type == "PathObject" { if node.object.Type == "PathObject" {
_, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source)) style, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source))
if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || !e.editorPaintable(node.object) { _, fillErr := e.Color(style.PathObject.FillColor)
_, strokeErr := e.Color((*FillColor)(style.PathObject.StrokeColor))
if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || fillErr != nil || strokeErr != nil {
node, err = e.wrapCompositePath(node) node, err = e.wrapCompositePath(node)
if err != nil { if err != nil {
return err return err
@@ -235,11 +237,11 @@ func (e *Editor) neutralDrawParam() (string, error) {
return e.addEditorDrawParam(DrawParam{LineWidth: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit, DashOffset: &zero, dashPatternSet: true, FillColor: fill, StrokeColor: (*StrokeColor)(fill)}) return e.addEditorDrawParam(DrawParam{LineWidth: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit, DashOffset: &zero, dashPatternSet: true, FillColor: fill, StrokeColor: (*StrokeColor)(fill)})
} }
// addEditorDrawParam 注册完整有效绘制参数,相同外观复用资源 // addEditorDrawParam 注册已校验的绘制参数,相同定义复用资源
// 入参: draw 不含继承关系的有效参数 // 入参: draw 绘制参数
// 返回: string 资源标识, error 错误信息 // 返回: string 资源标识, error 错误信息
func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) { func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) {
draw.ID, draw.Relative, draw.ResourceID, draw.BaseLoc, draw.Link = "", "", "", "", "" draw.ID, draw.ResourceID, draw.BaseLoc, draw.Link = "", "", "", ""
for _, resource := range e.resources { for _, resource := range e.resources {
if resource.draw != nil { if resource.draw != nil {
previous := *resource.draw previous := *resource.draw
@@ -257,10 +259,14 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) {
x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}) x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}})
x.start("DrawParams", nil) x.start("DrawParams", nil)
attrs := ofdAttrs{} attrs := ofdAttrs{}
for _, pair := range [][2]string{{"ID", draw.ID}, {"LineWidth", ofdNumber(draw.LineWidth)}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"MiterLimit", ofdNumber(draw.MiterLimit)}, {"Font", draw.Font}} { for _, pair := range [][2]string{{"ID", draw.ID}, {"Relative", draw.Relative}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"Font", draw.Font}} {
attrs.add(pair[0], pair[1]) attrs.add(pair[0], pair[1])
} }
attrs.number("LineWidth", draw.LineWidth)
attrs.number("MiterLimit", draw.MiterLimit)
if draw.Relative == "" || draw.dashPatternSet || draw.DashPattern != "" {
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern}) attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern})
}
if draw.DashOffset != nil { if draw.DashOffset != nil {
attrs.add("DashOffset", ofdNumber(*draw.DashOffset)) attrs.add("DashOffset", ofdNumber(*draw.DashOffset))
} }
+82 -3
View File
@@ -27,6 +27,88 @@ type AnnotationLink struct {
Dest *Dest Dest *Dest
} }
// SetObjectActions 原子替换普通对象的链接动作,空列表移除动作
// 入参: page 页面索引, ids 对象标识, actions 链接动作
// 返回: error 错误信息
func (e *Editor) SetObjectActions(page int, ids []string, actions []Action) error {
if err := validateObjectActions(actions); err != nil {
return err
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
for i := range objects {
switch objects[i].Type {
case "TextObject":
objects[i].TextObject.Actions = cloneEditorData(actions)
case "PathObject", "Path":
objects[i].PathObject.Actions = cloneEditorData(actions)
case "ImageObject":
objects[i].ImageObject.Actions = cloneEditorData(actions)
case "CompositeObject", "CompositeGraphicUnit":
objects[i].CompositeGraphicUnit.Actions = cloneEditorData(actions)
default:
return fmt.Errorf("object %q cannot contain actions", ids[i])
}
}
return e.updateObjects(page, objects, true)
}
// validateObjectActions 校验新建对象的链接动作,目标不必可达
// 入参: actions 动作列表
// 返回: error 错误信息
func validateObjectActions(actions []Action) error {
for _, action := range actions {
if !slices.Contains([]string{"CLICK", "DO", "PO"}, action.Event) {
return fmt.Errorf("unsupported action event %q", action.Event)
}
if action.GotoA != nil || action.Sound != nil || action.Movie != nil {
return fmt.Errorf("new attachment and multimedia actions require resource registration")
}
if (action.URI == nil) == (action.Goto == nil) {
return fmt.Errorf("specify one URI or goto action")
}
if action.Goto != nil {
if (action.Goto.Dest == nil) == (action.Goto.Bookmark == nil) {
return fmt.Errorf("specify one destination or bookmark")
}
if action.Goto.Dest != nil {
if _, err := annotationLinkXML(AnnotationLink{Dest: action.Goto.Dest}); err != nil {
return err
}
}
}
if action.Region != nil {
for _, area := range action.Region.Area {
if _, err := creationNumbers(area.Start, 2); err != nil {
return err
}
for _, command := range area.Command {
var points []string
switch command.Type {
case "Line":
points = []string{command.Point1}
case "QuadraticBezier":
points = []string{command.Point1, command.Point2}
case "CubicBezier":
points = []string{command.Point1, command.Point2, command.Point3}
case "Close":
default:
return fmt.Errorf("unsupported new action region command %q", command.Type)
}
for _, point := range points {
if _, err := creationNumbers(point, 2); err != nil {
return err
}
}
}
}
}
}
return nil
}
// AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作 // AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作
// 入参: page 页面索引, id 注解标识 // 入参: page 页面索引, id 注解标识
// 返回: Action 跳转动作, error 错误信息 // 返回: Action 跳转动作, error 错误信息
@@ -201,9 +283,6 @@ func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink
return nil, err return nil, err
} }
position := objects[0].open position := objects[0].open
if clips := objects[0].child("Clips"); clips != nil {
position = clips.end
}
data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}}) data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}})
} }
} }
+79 -16
View File
@@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io"
"reflect" "reflect"
) )
@@ -62,7 +63,7 @@ func (e *Editor) sourceInfoXML() ([]byte, error) {
return data, nil return data, nil
} }
// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点 // editorCustomDatas 更新自定义字段,保留原字段及容器的扩展内容
// 入参: data 原XML, info 文档信息节点, values 新字段 // 入参: data 原XML, info 文档信息节点, values 新字段
// 返回: []byte 修改后的XML, error 编码错误 // 返回: []byte 修改后的XML, error 编码错误
func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) { func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) {
@@ -82,9 +83,26 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
} }
} }
var encoded [][]byte var encoded [][]byte
used := make(map[*editorXML]bool)
for _, item := range items { for _, item := range items {
matches := byName[item.Name] if item.sourceIndex > 0 && item.sourceIndex <= len(nodes) {
if len(matches) == 0 { used[nodes[item.sourceIndex-1]] = true
}
}
for _, item := range items {
var node *editorXML
if item.sourceIndex > 0 && item.sourceIndex <= len(nodes) {
node = nodes[item.sourceIndex-1]
} else {
for _, candidate := range byName[item.Name] {
if !used[candidate] {
node = candidate
used[node] = true
break
}
}
}
if node == nil {
entry, err := xml.Marshal(struct { entry, err := xml.Marshal(struct {
XMLName xml.Name XMLName xml.Name
CustomData CustomData
@@ -95,26 +113,30 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
encoded = append(encoded, entry) encoded = append(encoded, entry)
continue continue
} }
node := matches[0]
byName[item.Name] = matches[1:]
var original CustomData var original CustomData
if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil { if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil {
return nil, err return nil, err
} }
if original.Value == item.Value { if original.Name != item.Name {
encoded = append(encoded, data[node.start:node.end]) entry, err := editorXMLAttribute(data, node, "Name", item.Name)
continue if err != nil {
}
var content bytes.Buffer
if err := xml.EscapeText(&content, []byte(item.Value)); err != nil {
return nil, err return nil, err
} }
for _, child := range node.children { renamed, err := parseEditorXML(entry)
content.Write(data[child.start:child.end]) if err != nil {
return nil, err
}
updated, err := editorCustomDataValue(entry, renamed, original.Value, item.Value)
if err != nil {
return nil, err
}
encoded = append(encoded, updated)
continue
}
entry, err := editorCustomDataValue(data, node, original.Value, item.Value)
if err != nil {
return nil, err
} }
patch := editorXMLContent(data, node, content.Bytes())
entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
entry = append(entry, data[patch.end:node.end]...)
encoded = append(encoded, entry) encoded = append(encoded, entry)
} }
if container == nil { if container == nil {
@@ -147,3 +169,44 @@ func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byt
} }
return editorPatchXML(data, patches), nil return editorPatchXML(data, patches), nil
} }
// editorCustomDataValue 修改字段文本,保留扩展子节点和注释
// 入参: data 原XML, node 字段节点, before 原值, after 新值
// 返回: []byte 字段XML, error 编码错误
func editorCustomDataValue(data []byte, node *editorXML, before, after string) ([]byte, error) {
if before == after {
return data[node.start:node.end], nil
}
var content bytes.Buffer
if err := xml.EscapeText(&content, []byte(after)); err != nil {
return nil, err
}
if node.open < node.close {
inner := data[node.open:node.close]
decoder := xml.NewDecoder(bytes.NewReader(inner))
depth := 0
for {
start := decoder.InputOffset()
token, err := decoder.RawToken()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
_, text := token.(xml.CharData)
if !text || depth != 0 {
content.Write(inner[start:decoder.InputOffset()])
}
switch token.(type) {
case xml.StartElement:
depth++
case xml.EndElement:
depth--
}
}
}
patch := editorXMLContent(data, node, content.Bytes())
entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
return append(entry, data[patch.end:node.end]...), nil
}
+14
View File
@@ -133,6 +133,11 @@ func (r *Reader) Editor() (*Editor, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if info.CustomDatas != nil {
for i := range info.CustomDatas.CustomData {
info.CustomDatas.CustomData[i].sourceIndex = i + 1
}
}
e := NewEditor() e := NewEditor()
e.encryption = r.encryption e.encryption = r.encryption
e.Info, e.maxID = cloneEditorData(*info), maximum e.Info, e.maxID = cloneEditorData(*info), maximum
@@ -527,6 +532,7 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX
all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor) all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor)
if origin.reason != nil { if origin.reason != nil {
all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false
all.Paint = (object.Type == "TextObject" || object.Type == "PathObject") && editReason(origin.reason) == EditUnsupportedColor
all.Reason, all.ReasonCode = origin.reason.Error(), editReason(origin.reason) all.Reason, all.ReasonCode = origin.reason.Error(), editReason(origin.reason)
} else if !editorXMLSupported(node) { } else if !editorXMLSupported(node) {
all.Update, all.Reflow, all.ReplaceFont = false, false, false all.Update, all.Reflow, all.ReplaceFont = false, false, false
@@ -632,6 +638,14 @@ func editorPreservedObject(before, after GraphicObject) bool {
// 返回: GraphicObject 独立副本, error 错误信息 // 返回: GraphicObject 独立副本, error 错误信息
func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicObject, error) { func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicObject, error) {
origin := e.snapshotOrigin(object) origin := e.snapshotOrigin(object)
if origin == nil && object.origin != nil {
references := make(map[string]bool)
collectObjectReferences(object, references)
delete(references, "")
if len(references) != 0 {
return GraphicObject{}, fmt.Errorf("copying a foreign snapshot requires importing its resources")
}
}
object.origin = nil object.origin = nil
if origin == nil { if origin == nil {
return e.prepareObject(id, object) return e.prepareObject(id, object)
+74 -1
View File
@@ -21,6 +21,37 @@ import (
"strings" "strings"
) )
// AddDrawParam 注册绘制参数,保留继承引用,未引用的资源不写入文档
// 入参: draw 绘制参数,ID由编辑器分配
// 返回: string 资源标识, error 错误信息
func (e *Editor) AddDrawParam(draw DrawParam) (string, error) {
if draw.ResourceID != "" || draw.BaseLoc != "" || draw.Link != "" {
return "", fmt.Errorf("external draw parameter references cannot be registered directly")
}
base, err := e.editorDrawParam(draw.Relative, make(map[string]bool))
if err != nil {
return "", err
}
effective := mergeDrawParam(*base, &draw)
if err := validateEditorStroke(PathObject{LineWidth: draw.LineWidth, Cap: draw.Cap, Join: draw.Join, MiterLimit: draw.MiterLimit, DashPattern: draw.DashPattern, DashOffset: draw.DashOffset}); err != nil {
return "", err
}
if !finite(draw.Size) || draw.Size < 0 || draw.Weight < 0 || draw.Weight > 900 || draw.Weight%100 != 0 {
return "", fmt.Errorf("invalid draw parameter font size or weight")
}
if effective.Font != "" {
if _, err := e.editorFont(effective.Font); err != nil {
return "", err
}
}
for _, paint := range []*FillColor{draw.FillColor, (*FillColor)(draw.StrokeColor)} {
if err := e.editorColor(paint); err != nil {
return "", err
}
}
return e.addEditorDrawParam(cloneEditorData(draw))
}
// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照 // editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照
// 入参: id 绘制参数标识, visited 已访问标识 // 入参: id 绘制参数标识, visited 已访问标识
// 返回: *DrawParam 合并参数, error 错误信息 // 返回: *DrawParam 合并参数, error 错误信息
@@ -121,7 +152,7 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara
obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor
} }
for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} {
if err := e.editorColor(color); err != nil { if _, err := e.Color(color); err != nil {
return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("unsupported draw parameter color: %w", err)} return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("unsupported draw parameter color: %w", err)}
} }
} }
@@ -413,6 +444,48 @@ func editorStrokeScale(ctm string) float64 {
return 1 return 1
} }
// SetImageBorders 原子替换图片边框,nil移除边框
// 入参: page 页面索引, ids 图片标识, border 边框样式
// 返回: error 错误信息
func (e *Editor) SetImageBorders(page int, ids []string, border *ImageBorder) error {
if err := e.validateImageBorder(border); err != nil {
return err
}
objects, _, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
for i := range objects {
if objects[i].Type != "ImageObject" {
return fmt.Errorf("object %q is not an image", ids[i])
}
objects[i].ImageObject.Border = cloneEditorData(border)
}
return e.updateObjects(page, objects, true)
}
// validateImageBorder 校验图片边框的线型、圆角和绘制颜色
// 入参: border 边框
// 返回: error 错误信息
func (e *Editor) validateImageBorder(border *ImageBorder) error {
if border == nil {
return nil
}
for _, radius := range []float64{border.HorizonalCornerRadius, border.VerticalCornerRadius} {
if !finite(radius) || radius < 0 {
return fmt.Errorf("image corner radius must be finite and nonnegative")
}
}
stroke := PathObject{DashPattern: border.DashPattern, DashOffset: &border.DashOffset}
if border.LineWidth != nil {
stroke.LineWidth = *border.LineWidth
}
if err := validateEditorStroke(stroke); err != nil {
return err
}
return e.editorColor((*FillColor)(border.BorderColor))
}
// validateEditorStroke 校验路径描边尺寸、端点和虚线 // validateEditorStroke 校验路径描边尺寸、端点和虚线
// 入参: path 路径对象 // 入参: path 路径对象
// 返回: error 错误信息 // 返回: error 错误信息
+48 -9
View File
@@ -18,6 +18,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"strconv"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
@@ -112,13 +113,22 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
if err != nil { if err != nil {
return err return err
} }
if !capability.ReplaceFont { if !capability.ReplaceFont && (style != (TextStyle{}) || !capability.Transform) {
return capability.editError() return capability.editError()
} }
object, err := e.Object(page, id) object, err := e.Object(page, id)
if err != nil { if err != nil {
return err return err
} }
if !capability.LayoutKnown && style == (TextStyle{}) {
if object.Type != "TextObject" {
return fmt.Errorf("object %q is not text", id)
}
if err := e.RewriteText(&object.TextObject, value); err != nil {
return err
}
return e.updateObjects(page, []GraphicObject{object}, true)
}
updates, err := e.styleTextObjects([]GraphicObject{object}, style) updates, err := e.styleTextObjects([]GraphicObject{object}, style)
if err != nil { if err != nil {
return err return err
@@ -136,20 +146,18 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle)
return e.updateObjects(page, updates, true) return e.updateObjects(page, updates, true)
} }
// RewriteText 保留普通横向原文的字形定位,不修改对象样式或文档 // RewriteText 保留原文的字形定位,不修改对象样式或文档
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距 // 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
// 不跨定位段增删或自动生成新行,复杂字形映射需单独处理 // 方向、缩放和修饰原样保留;复杂布局仅支持等长替换,合并字形需显式重排
// 入参: obj 原文字对象, value 新内容 // 入参: obj 原文字对象, value 新内容
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) RewriteText(obj *TextObject, value string) error { func (e *Editor) RewriteText(obj *TextObject, value string) error {
if obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != "" {
return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text positioning is not supported for content editing")}
}
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" { if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
return fmt.Errorf("text must contain UTF-8 characters without tabs") return fmt.Errorf("text must contain UTF-8 characters without tabs")
} }
check := cloneEditorData(*obj) check := cloneEditorData(*obj)
check.VScale, check.Decoration = 0, ""
if err := e.prepareText(&check); err != nil { if err := e.prepareText(&check); err != nil {
var missing *MissingGlyphError var missing *MissingGlyphError
if !errors.As(err, &missing) { if !errors.As(err, &missing) {
@@ -160,8 +168,16 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
if err != nil { if err != nil {
return err return err
} }
old, next := []rune(obj.Text()), []rune(value)
complex := obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != ""
if complex && len(old) != len(next) {
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")}
}
var missing []rune var missing []rune
for _, char := range value { for i, char := range next {
if len(old) == len(next) && old[i] == char {
continue
}
if char != '\n' && sfnt.GlyphIndex(char) == 0 { if char != '\n' && sfnt.GlyphIndex(char) == 0 {
missing = append(missing, char) missing = append(missing, char)
} }
@@ -169,8 +185,8 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
if err := missingGlyphError(obj.Font, missing); err != nil { if err := missingGlyphError(obj.Font, missing); err != nil {
return err return err
} }
old, next := []rune(obj.Text()), []rune(value)
codes := append([]TextCode(nil), obj.TextCode...) codes := append([]TextCode(nil), obj.TextCode...)
transforms := append([]CGTransform(nil), obj.CGTransform...)
if len(old) == len(next) { if len(old) == len(next) {
offset := 0 offset := 0
for i := range codes { for i := range codes {
@@ -240,10 +256,33 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
} }
check = *obj check = *obj
check.TextCode = codes check.TextCode = codes
position := 0
for i, code := range codes {
before, after := textCodeRunes(obj.TextCode[i].Value), textCodeRunes(code.Value)
if len(before) == len(after) {
for j, char := range after {
if before[j] == char {
continue
}
for k := range transforms {
transform := &transforms[k]
if position+j < transform.CodePosition || position+j >= transform.CodePosition+transform.CodeCount {
continue
}
if transform.CodeCount != 1 || transform.GlyphCount != 1 {
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing a combined glyph requires explicit layout")}
}
transform.Glyphs = strconv.Itoa(int(sfnt.GlyphIndex(char)))
}
}
}
position += len(after)
}
check.CGTransform = transforms
if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: check}); err != nil { if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: check}); err != nil {
return err return err
} }
obj.TextCode, obj.layout = codes, nil obj.TextCode, obj.CGTransform, obj.layout = codes, transforms, nil
return nil return nil
} }
+5 -1
View File
@@ -49,11 +49,13 @@ type FontShaper interface {
ShapeText(value string, size float64) ([]ShapedGlyph, error) ShapeText(value string, size float64) ([]ShapedGlyph, error)
} }
// TextShapeOptions 指定单一方向文字的塑形参数,不执行双向段落分析或字体回退 // TextShapeOptions 指定文字塑形参数,Bidi启用双向及脚本分段,不执行字体回退
// Direction为空或ltr时从左向右,rtl时从右向左;Script为四字母ISO15924代码,空值自动检测 // Direction为空或ltr时从左向右,rtl时从右向左;Script为四字母ISO15924代码,空值自动检测
// Bidi启用时Direction指定段落方向,各文字片段按双向算法排列
// Language为语言标签,Features为逗号分隔的全局OpenType特性,如kern=0,liga=1,不接受字符区间 // Language为语言标签,Features为逗号分隔的全局OpenType特性,如kern=0,liga=1,不接受字符区间
// 零值保持原有FontShaper行为,非零值要求FontShaperOptions能力 // 零值保持原有FontShaper行为,非零值要求FontShaperOptions能力
type TextShapeOptions struct { type TextShapeOptions struct {
Bidi bool
Direction string Direction string
Script string Script string
Language string Language string
@@ -68,9 +70,11 @@ type FontShaperOptions interface {
} }
// ShapedGlyph 保存塑形后的字形、原文簇和基线定位 // ShapedGlyph 保存塑形后的字形、原文簇和基线定位
// Bidi模式下VisualOrder为簇从左到右的零起始顺序,同簇字形共用顺序
type ShapedGlyph struct { type ShapedGlyph struct {
Glyph uint16 Glyph uint16
Cluster int Cluster int
VisualOrder int
X, Y, Advance float64 X, Y, Advance float64
} }
+82 -2
View File
@@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"fmt" "fmt"
"math"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -25,9 +26,12 @@ import (
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"github.com/go-text/typesetting/di"
typefont "github.com/go-text/typesetting/font" typefont "github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/harfbuzz" "github.com/go-text/typesetting/harfbuzz"
"github.com/go-text/typesetting/language" "github.com/go-text/typesetting/language"
"github.com/go-text/typesetting/shaping"
"golang.org/x/image/math/fixed"
xlanguage "golang.org/x/text/language" xlanguage "golang.org/x/text/language"
) )
@@ -36,6 +40,7 @@ type fontShaper struct {
metrics FontMetrics metrics FontMetrics
shapeMu sync.Mutex shapeMu sync.Mutex
shapeFont *harfbuzz.Font shapeFont *harfbuzz.Font
shapeFace *typefont.Face
shapeBuffer *harfbuzz.Buffer shapeBuffer *harfbuzz.Buffer
} }
@@ -46,8 +51,7 @@ func (f *fontShaper) ShapeText(value string, size float64) ([]ShapedGlyph, error
return f.ShapeTextWithOptions(value, size, TextShapeOptions{}) return f.ShapeTextWithOptions(value, size, TextShapeOptions{})
} }
// ShapeTextWithOptions 使用现有纯Go塑形引擎处理单一方向原文,保留逻辑簇和视觉位置 // ShapeTextWithOptions 使用纯Go塑形引擎保留逻辑簇和视觉位置,可显式启用双向分段
// 不执行混合方向分段,调用方应将双向段落拆为单向文字对象
// 入参: value 单行原文, size 毫米字号, options 塑形选项 // 入参: value 单行原文, size 毫米字号, options 塑形选项
// 返回: []ShapedGlyph 定位字形, error 字符或选项错误 // 返回: []ShapedGlyph 定位字形, error 字符或选项错误
func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error) { func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error) {
@@ -114,8 +118,12 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te
return nil, fmt.Errorf("shaping font: %w", err) return nil, fmt.Errorf("shaping font: %w", err)
} }
f.shapeFont = harfbuzz.NewFont(face) f.shapeFont = harfbuzz.NewFont(face)
f.shapeFace = face
f.shapeBuffer = harfbuzz.NewBuffer() f.shapeBuffer = harfbuzz.NewBuffer()
} }
if options.Bidi {
return f.shapeBidiText([]rune(value), size, props, features)
}
buffer := f.shapeBuffer buffer := f.shapeBuffer
buffer.Clear() buffer.Clear()
buffer.Props = props buffer.Props = props
@@ -143,3 +151,75 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te
slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) }) slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) })
return result, nil return result, nil
} }
// shapingFace 固定双向排版的字体来源,不隐式替换字体
type shapingFace struct{ face *typefont.Face }
// ResolveFace 返回指定字体
// 入参: char 字符
// 返回: *typefont.Face 字体
func (f shapingFace) ResolveFace(char rune) *typefont.Face { return f.face }
// shapeBidiText 使用现有分段和行排列引擎生成单行双向字形
// 入参: runes 原文, size 毫米字号, props 段落属性, features 字体特性
// 返回: []ShapedGlyph 字形, error 错误信息
func (f *fontShaper) shapeBidiText(runes []rune, size float64, props harfbuzz.SegmentProperties, features []harfbuzz.Feature) ([]ShapedGlyph, error) {
if len(runes) == 0 {
return nil, nil
}
direction := di.DirectionLTR
if props.Direction == harfbuzz.RightToLeft {
direction = di.DirectionRTL
}
input := shaping.Input{Text: runes, RunEnd: len(runes), Direction: direction, Face: f.shapeFace, Size: fixed.I(int(f.metrics.UnitsPerEm())), Script: props.Script, Language: props.Language}
for _, feature := range features {
input.FontFeatures = append(input.FontFeatures, shaping.FontFeature{Tag: feature.Tag, Value: feature.Value})
}
var segmenter shaping.Segmenter
var shaper shaping.HarfbuzzShaper
var wrapper shaping.LineWrapper
var runs []shaping.Output
var advance int64
for _, part := range segmenter.Split(input, shapingFace{f.shapeFace}) {
if props.Script != 0 {
part.Script = props.Script
}
if props.Language != "" {
part.Language = props.Language
}
run := shaper.Shape(part)
for _, glyph := range run.Glyphs {
advance += int64(glyph.Advance)
}
if advance < 0 || advance >= math.MaxInt32 {
return nil, fmt.Errorf("shaped line exceeds coordinate range")
}
runs = append(runs, run)
}
lines, truncated := wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(math.MaxInt32), runes, shaping.NewSliceIterator(runs))
if truncated != 0 || len(lines) != 1 {
return nil, fmt.Errorf("shaping requires a single text line")
}
slices.SortFunc(lines[0], func(a, b shaping.Output) int { return cmp.Compare(a.VisualIndex, b.VisualIndex) })
unit := size / float64(f.metrics.UnitsPerEm()) / 64
var result []ShapedGlyph
var x float64
visual := make(map[int]int)
for _, run := range lines[0] {
for _, glyph := range run.Glyphs {
if glyph.GlyphID >= typefont.GID(f.metrics.NumGlyphs()) {
return nil, fmt.Errorf("invalid shaped glyph index %d", glyph.GlyphID)
}
cluster := glyph.TextIndex()
order, exists := visual[cluster]
if !exists {
order = len(visual)
visual[cluster] = order
}
result = append(result, ShapedGlyph{Glyph: uint16(glyph.GlyphID), Cluster: cluster, VisualOrder: order, X: (x + float64(glyph.XOffset)) * unit, Y: -float64(glyph.YOffset) * unit, Advance: float64(glyph.Advance) * unit})
x += float64(glyph.Advance)
}
}
slices.SortStableFunc(result, func(a, b ShapedGlyph) int { return cmp.Compare(a.Cluster, b.Cluster) })
return result, nil
}
+1
View File
@@ -54,4 +54,5 @@ type CustomDatas struct {
type CustomData struct { type CustomData struct {
Name string `xml:"Name,attr"` Name string `xml:"Name,attr"`
Value string `xml:",chardata"` Value string `xml:",chardata"`
sourceIndex int
} }
+15 -7
View File
@@ -224,6 +224,19 @@ func (r *Renderer) parseShdColor(segments []ShdSegment, alpha *int) color.Color
// 返回: []ColorStop 后端无关的渐变分段 // 返回: []ColorStop 后端无关的渐变分段
func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop { func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop {
var gradient []ColorStop var gradient []ColorStop
positions := gradientPositions(segments)
for i, segment := range segments {
segmentAlpha := mergeAlpha(segment.Color.Alpha, alpha)
gradient = append(gradient, ColorStop{Offset: positions[i], Color: r.ResolveColor(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha)})
}
return gradient
}
// gradientPositions 按显式锚点补全渐变分段位置,保留原顺序
// 入参: segments 渐变分段
// 返回: []float64 分段位置
func gradientPositions(segments []ShdSegment) []float64 {
positions := make([]float64, len(segments))
position, step := 0.0, 0.0 position, step := 0.0, 0.0
for i, segment := range segments { for i, segment := range segments {
if !segment.positionMissing { if !segment.positionMissing {
@@ -242,15 +255,10 @@ func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop
} }
step = (end - position) / float64(next-i) step = (end - position) / float64(next-i)
} }
offset := position positions[i] = position
position += step position += step
segmentAlpha := mergeAlpha(segment.Color.Alpha, alpha)
gradient = append(gradient, ColorStop{Offset: offset, Color: r.ResolveColor(segment.Color.Value, segment.Color.Index, segment.Color.ColorSpace, segmentAlpha)})
} }
if len(gradient) == 0 { return positions
return nil
}
return gradient
} }
// colorToRGBA 转换颜色对象 // colorToRGBA 转换颜色对象
+38 -1
View File
@@ -119,9 +119,15 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))} code := TextCode{X: ofdNumber(x), Y: ofdNumber(y), Value: escapeOFDText(string(chars))}
var dx, dy []string var dx, dy []string
var lastX, lastY float64 var lastX, lastY float64
var visualShifts map[int]float64
if options.Shaping.Bidi {
visualShifts = bidiClusterShifts(glyphs, extra)
}
for j, glyph := range glyphs { for j, glyph := range glyphs {
shift := extra[glyph.Cluster] shift := extra[glyph.Cluster]
if options.Shaping.Direction == "rtl" { if options.Shaping.Bidi {
shift = visualShifts[glyph.Cluster]
} else if options.Shaping.Direction == "rtl" {
// 逻辑簇后的间距位于视觉左侧,整行不计末尾字距 // 逻辑簇后的间距位于视觉左侧,整行不计末尾字距
shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing shift = extra[len(chars)] - extra[glyph.Cluster] - options.LetterSpacing
} }
@@ -162,6 +168,37 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics
return nil return nil
} }
// bidiClusterShifts 按视觉簇顺序累计字距和两端对齐产生的偏移
// 入参: glyphs 逻辑顺序字形, extra 逻辑字符累计偏移
// 返回: map[int]float64 各簇的视觉偏移
func bidiClusterShifts(glyphs []ShapedGlyph, extra []float64) map[int]float64 {
type cluster struct {
index, order int
extra float64
}
var clusters []cluster
for i := 0; i < len(glyphs); {
end := i + 1
for end < len(glyphs) && glyphs[end].Cluster == glyphs[i].Cluster {
end++
}
next := len(extra) - 1
if end < len(glyphs) {
next = glyphs[end].Cluster
}
clusters = append(clusters, cluster{glyphs[i].Cluster, glyphs[i].VisualOrder, extra[next] - extra[glyphs[i].Cluster]})
i = end
}
slices.SortFunc(clusters, func(a, b cluster) int { return a.order - b.order })
shifts := make(map[int]float64, len(clusters))
var offset float64
for _, cluster := range clusters {
shifts[cluster.index] = offset
offset += cluster.extra
}
return shifts
}
// ShapeText 使用固定选项塑形单行原文 // ShapeText 使用固定选项塑形单行原文
// 入参: value 原文, size 毫米字号 // 入参: value 原文, size 毫米字号
// 返回: []ShapedGlyph 字形, error 塑形错误 // 返回: []ShapedGlyph 字形, error 塑形错误
+177 -35
View File
@@ -375,45 +375,15 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
for _, layer := range page.Content.Layer { for _, layer := range page.Content.Layer {
for _, object := range layer.Objects { for _, object := range layer.Objects {
before, exists := original[editorObjectID(object)] before, exists := original[editorObjectID(object)]
collectObjectReferences(object, used)
if e.source != nil && (!exists || !reflect.DeepEqual(before, object)) {
collectObjectReferences(object, promoted)
}
if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.page != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.page != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) {
for _, name := range origin.page.original.PageRes { for _, name := range origin.page.original.PageRes {
files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true
} }
} }
for _, value := range []*FillColor{object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor), object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)} {
if value != nil && value.ColorSpace != "" {
used[value.ColorSpace] = true
if e.source != nil && (!exists || !reflect.DeepEqual(before, object)) {
promoted[value.ColorSpace] = true
}
}
}
switch object.Type {
case "CompositeObject", "CompositeGraphicUnit":
collectCompositeReferences(object.CompositeGraphicUnit, used)
if e.source != nil && (!exists || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) {
collectCompositeReferences(object.CompositeGraphicUnit, promoted)
}
case "TextObject":
used[object.TextObject.DrawParam] = true
used[object.TextObject.Font] = true
if e.source != nil && (!exists || before.Type != object.Type || before.TextObject.Font != object.TextObject.Font) {
promoted[object.TextObject.Font] = true
}
case "ImageObject":
used[object.ImageObject.ResourceID] = true
if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ResourceID != object.ImageObject.ResourceID) {
promoted[object.ImageObject.ResourceID] = true
}
if object.ImageObject.ImageMask != "" {
used[object.ImageObject.ImageMask] = true
if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ImageMask != object.ImageObject.ImageMask) {
promoted[object.ImageObject.ImageMask] = true
}
}
case "PathObject":
used[object.PathObject.DrawParam] = true
}
} }
} }
} }
@@ -663,6 +633,7 @@ func encodeOFDXML(encode func(*ofdXML)) ([]byte, error) {
func (x *ofdXML) object(object GraphicObject, root bool) { func (x *ofdXML) object(object GraphicObject, root bool) {
var attrs ofdAttrs var attrs ofdAttrs
var fill, stroke *FillColor var fill, stroke *FillColor
var actions []Action
if root { if root {
attrs.add("xmlns:ofd", ofdNamespace) attrs.add("xmlns:ofd", ofdNamespace)
} }
@@ -676,6 +647,8 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("Font", obj.Font) attrs.add("Font", obj.Font)
attrs.number("Size", obj.Size) attrs.number("Size", obj.Size)
attrs.number("HScale", obj.HScale) attrs.number("HScale", obj.HScale)
attrs.number("VScale", obj.VScale)
attrs.add("Decoration", obj.Decoration)
attrs.number("Weight", float64(obj.Weight)) attrs.number("Weight", float64(obj.Weight))
attrs.number("ReadDirection", float64(obj.ReadDirection)) attrs.number("ReadDirection", float64(obj.ReadDirection))
attrs.number("CharDirection", float64(obj.CharDirection)) attrs.number("CharDirection", float64(obj.CharDirection))
@@ -690,6 +663,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.flag("Fill", obj.Fill) attrs.flag("Fill", obj.Fill)
attrs.alpha(obj.Alpha) attrs.alpha(obj.Alpha)
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor) fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
actions = obj.Actions
case "PathObject", "Path": case "PathObject", "Path":
obj := object.PathObject obj := object.PathObject
attrs.add("ID", obj.ID) attrs.add("ID", obj.ID)
@@ -714,6 +688,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.flag("Fill", obj.Fill) attrs.flag("Fill", obj.Fill)
attrs.alpha(obj.Alpha) attrs.alpha(obj.Alpha)
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor) fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
actions = obj.Actions
case "ImageObject": case "ImageObject":
obj := object.ImageObject obj := object.ImageObject
attrs.add("ID", obj.ID) attrs.add("ID", obj.ID)
@@ -723,6 +698,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("ImageMask", obj.ImageMask) attrs.add("ImageMask", obj.ImageMask)
attrs.flag("Visible", obj.Visible) attrs.flag("Visible", obj.Visible)
attrs.alpha(obj.Alpha) attrs.alpha(obj.Alpha)
actions = obj.Actions
case "CompositeObject", "CompositeGraphicUnit": case "CompositeObject", "CompositeGraphicUnit":
obj := object.CompositeGraphicUnit obj := object.CompositeGraphicUnit
attrs.add("ID", obj.ID) attrs.add("ID", obj.ID)
@@ -732,10 +708,13 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("DrawParam", obj.DrawParam) attrs.add("DrawParam", obj.DrawParam)
attrs.flag("Visible", obj.Visible) attrs.flag("Visible", obj.Visible)
attrs.alpha(obj.Alpha) attrs.alpha(obj.Alpha)
actions = obj.Actions
} }
x.start(object.Type, attrs) x.start(object.Type, attrs)
x.actions(actions)
if object.Type == "ImageObject" { if object.Type == "ImageObject" {
x.clips(object.ImageObject.Clips) x.clips(object.ImageObject.Clips)
x.border(object.ImageObject.Border)
} else if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { } else if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" {
x.clips(object.CompositeGraphicUnit.Clips) x.clips(object.CompositeGraphicUnit.Clips)
} else if object.Type == "PathObject" || object.Type == "Path" { } else if object.Type == "PathObject" || object.Type == "Path" {
@@ -816,7 +795,7 @@ func (x *ofdXML) textCode(value string, attrs ofdAttrs) {
x.err = x.encoder.EncodeElement(content, xml.StartElement{Name: xml.Name{Local: "ofd:TextCode"}, Attr: attrs}) x.err = x.encoder.EncodeElement(content, xml.StartElement{Name: xml.Name{Local: "ofd:TextCode"}, Attr: attrs})
} }
// color 写出基本颜色 // color 写出纯色、渐变和图案,保留颜色空间与透明度
// 入参: name 节点名, color 颜色 // 入参: name 节点名, color 颜色
func (x *ofdXML) color(name string, color *FillColor) { func (x *ofdXML) color(name string, color *FillColor) {
if color == nil { if color == nil {
@@ -830,5 +809,168 @@ func (x *ofdXML) color(name string, color *FillColor) {
} }
attrs.alpha(color.Alpha) attrs.alpha(color.Alpha)
x.start(name, attrs) x.start(name, attrs)
if color.AxialShd != nil {
shading := color.AxialShd
x.shading("AxialShd", shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint), shading.Segment)
}
if color.RadialShd != nil {
shading := color.RadialShd
attrs := shadingAttrs(shading.MapType, shading.MapUnit, shading.Extend, shading.StartPoint, shading.EndPoint)
attrs.add("StartRadius", ofdNumber(shading.StartRadius))
attrs.add("EndRadius", ofdNumber(shading.EndRadius))
attrs.number("Eccentricity", shading.Eccentricity)
attrs.number("Angle", shading.Angle)
x.shading("RadialShd", attrs, shading.Segment)
}
if color.Pattern != nil {
x.pattern(color.Pattern)
}
x.end(name) x.end(name)
} }
// shadingAttrs 编码轴向和径向渐变的共同属性
// 入参: mapType 映射方式, mapUnit 映射周期, extend 延伸方式, start、end 渐变端点
// 返回: ofdAttrs 属性集合
func shadingAttrs(mapType string, mapUnit float64, extend, start, end string) ofdAttrs {
var attrs ofdAttrs
attrs.add("MapType", mapType)
attrs.number("MapUnit", mapUnit)
attrs.add("Extend", extend)
attrs.add("StartPoint", start)
attrs.add("EndPoint", end)
return attrs
}
// shading 写出渐变分段,保留未显式设置的位置
// 入参: name 渐变类型, attrs 属性, segments 色标
func (x *ofdXML) shading(name string, attrs ofdAttrs, segments []ShdSegment) {
x.start(name, attrs)
for _, segment := range segments {
var attrs ofdAttrs
if !segment.positionMissing {
attrs.add("Position", ofdNumber(segment.Position))
}
x.start("Segment", attrs)
x.element("Color", segment.Color)
x.end("Segment")
}
x.end(name)
}
// element 写出具有标准XML标签的子树,显式声明OFD命名空间
// 入参: name 元素名称, value 元素内容
func (x *ofdXML) element(name string, value any) {
if x.err == nil {
x.err = x.encoder.EncodeElement(value, xml.StartElement{Name: xml.Name{Space: ofdNamespace, Local: name}})
}
}
// border 写出图片边框及其绘制颜色
// 入参: border 边框,nil不输出节点
func (x *ofdXML) border(border *ImageBorder) {
if border == nil {
return
}
var attrs ofdAttrs
if border.LineWidth != nil {
attrs.add("LineWidth", ofdNumber(*border.LineWidth))
}
attrs.number("HorizonalCornerRadius", border.HorizonalCornerRadius)
attrs.number("VerticalCornerRadius", border.VerticalCornerRadius)
attrs.number("DashOffset", border.DashOffset)
attrs.add("DashPattern", border.DashPattern)
x.start("Border", attrs)
x.color("BorderColor", (*FillColor)(border.BorderColor))
x.end("Border")
}
// pattern 写出图案单元,保留对象绘制顺序
// 入参: pattern 图案
func (x *ofdXML) pattern(pattern *Pattern) {
var attrs ofdAttrs
attrs.number("Width", pattern.Width)
attrs.number("Height", pattern.Height)
attrs.number("XStep", pattern.XStep)
attrs.number("YStep", pattern.YStep)
attrs.add("ReflectMethod", pattern.ReflectMethod)
attrs.add("RelativeTo", pattern.RelativeTo)
attrs.add("CTM", pattern.CTM)
x.start("Pattern", attrs)
x.start("CellContent", nil)
for _, object := range pattern.CellContent.Objects {
x.object(object, false)
}
x.end("CellContent")
x.end("Pattern")
}
// actions 写出对象动作及精确点击区域,不检查链接可达性
// 入参: actions 动作列表
func (x *ofdXML) actions(actions []Action) {
if len(actions) == 0 {
return
}
x.start("Actions", nil)
for _, action := range actions {
var attrs ofdAttrs
attrs.add("Event", action.Event)
x.start("Action", attrs)
if action.Region != nil {
x.start("Region", nil)
for _, area := range action.Region.Area {
x.start("Area", ofdAttrs{{Name: xml.Name{Local: "Start"}, Value: area.Start}})
for _, command := range area.Command {
var attrs ofdAttrs
for _, pair := range [][2]string{{"Point1", command.Point1}, {"Point2", command.Point2}, {"Point3", command.Point3}, {"EllipseSize", command.EllipseSize}, {"RotationAngle", command.RotationAngle}, {"LargeArc", command.LargeArc}, {"SweepDirection", command.SweepDirection}, {"EndPoint", command.EndPoint}} {
attrs.add(pair[0], pair[1])
}
x.start(command.Type, attrs)
x.end(command.Type)
}
x.end("Area")
}
x.end("Region")
}
if action.Goto != nil {
x.start("Goto", nil)
if dest := action.Goto.Dest; dest != nil {
attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}}
switch dest.Type {
case "XYZ":
attrs.add("Left", ofdNumber(dest.Left))
attrs.add("Top", ofdNumber(dest.Top))
attrs.add("Zoom", ofdNumber(dest.Zoom))
case "FitH":
attrs.add("Top", ofdNumber(dest.Top))
case "FitV":
attrs.add("Left", ofdNumber(dest.Left))
case "FitR":
attrs.add("Left", ofdNumber(dest.Left))
attrs.add("Right", ofdNumber(dest.Right))
attrs.add("Top", ofdNumber(dest.Top))
attrs.add("Bottom", ofdNumber(dest.Bottom))
}
x.start("Dest", attrs)
x.end("Dest")
}
if action.Goto.Bookmark != nil {
x.element("Bookmark", action.Goto.Bookmark)
}
x.end("Goto")
}
if action.URI != nil {
x.element("URI", action.URI)
}
if action.GotoA != nil {
x.element("GotoA", action.GotoA)
}
if action.Sound != nil {
x.element("Sound", action.Sound)
}
if action.Movie != nil {
x.element("Movie", action.Movie)
}
x.end("Action")
}
x.end("Actions")
}