feat(转换能力): 完善转换渲染,优化字体加载
build.yaml / build (push) Successful in 40s

This commit is contained in:
xiaoqidun committed 2026-09-27 08:03:10 +08:00
1 parent 983a856736
commit 98d9effc8c
18 files changed
+672 -137

No files matched your search

+32 -18
View File
@@ -3575,19 +3575,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
} }
async function selectLocalFonts(fonts) { async function selectLocalFonts(fonts) {
const available = new Map(); const available = fontManager.localEntries(fonts);
for (const font of fonts) {
const names = [
fontManager.localName(font),
font.postscriptName && `${font.postscriptName}.ttf`,
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`,
];
for (const name of names) {
if (name && !available.has(name)) {
available.set(name, font);
}
}
}
const matched = await callWASM("ofdgoMatchFontFiles", [...available.keys()]); const matched = await callWASM("ofdgoMatchFontFiles", [...available.keys()]);
return [...new Set(matched.map((name) => available.get(name)))]; return [...new Set(matched.map((name) => available.get(name)))];
} }
@@ -4502,11 +4490,15 @@ function updateBatchControls() {
batchElements.Cancel.disabled = batch.canceled; batchElements.Cancel.disabled = batch.canceled;
batchElements.Close.textContent = batch.running ? "收起" : "关闭"; batchElements.Close.textContent = batch.running ? "收起" : "关闭";
batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : ""; batchElements.Count.textContent = batch.items.length ? `${batch.items.length}个文件` : "";
const formats = batch.items.length ? batch.items.map(item => item.format || batchElements.Format.value) : [batchElements.Format.value]; batchElements.DPIRow.hidden = !(batch.items.length ? batch.items : [null]).some(batchUsesDPI);
batchElements.DPIRow.hidden = !formats.some(value => batch.formats.find(format => format.value === value)?.raster);
batchElements.Button.toggleAttribute("data-running", batch.running); batchElements.Button.toggleAttribute("data-running", batch.running);
} }
function batchUsesDPI(item) {
const format = item?.format || batchElements.Format.value;
return Boolean(batch.formats.find(value => value.value === format)?.raster || item && /\.pdf$/i.test(item.file.name));
}
function batchStatus(item, status, text) { function batchStatus(item, status, text) {
item.status = status; item.status = status;
item.text = text; item.text = text;
@@ -4558,12 +4550,24 @@ function startBatchWorker() {
}; };
worker.onerror = event => fail(new Error(event.message || "转换引擎异常")); worker.onerror = event => fail(new Error(event.message || "转换引擎异常"));
worker.onmessageerror = () => fail(new Error("转换数据传递失败")); worker.onmessageerror = () => fail(new Error("转换数据传递失败"));
worker.onmessage = ({ data }) => { worker.onmessage = async ({ data }) => {
if (data.type === "ready") { resolve(); return; } if (data.type === "ready") { resolve(); return; }
if (data.type === "exit") { fail(new Error(data.error)); return; } if (data.type === "exit") { fail(new Error(data.error)); return; }
if (data.type === "progress") { batchElements.Status.textContent = data.text; return; } if (data.type === "progress") { batchElements.Status.textContent = data.text; return; }
const request = batch.requests.get(data.id); const request = batch.requests.get(data.id);
if (!request) return; if (!request) return;
if (data.type === "font") {
try {
const font = batch.localFonts.get(data.name);
if (!font) throw new Error("系统字体不可用");
if (!batch.fontData.has(font)) batch.fontData.set(font, fontManager.read(font));
const bytes = await batch.fontData.get(font);
worker.postMessage({ type: "font", request: data.request, bytes });
} catch (err) {
worker.postMessage({ type: "font", request: data.request, error: err.message });
}
return;
}
if (data.type === "output") { request.outputName = data.name; return; } if (data.type === "output") { request.outputName = data.name; return; }
if (data.type === "conversion") { request.onProgress?.(data); return; } if (data.type === "conversion") { request.onProgress?.(data); return; }
batch.requests.delete(data.id); batch.requests.delete(data.id);
@@ -4593,9 +4597,10 @@ async function runBatch() {
const dpi = Number(batchElements.DPI.value); const dpi = Number(batchElements.DPI.value);
const archive = batchElements.Destination.value === "archive"; const archive = batchElements.Destination.value === "archive";
const backend = state.renderBackend; const backend = state.renderBackend;
const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])];
for (const item of batch.items) { for (const item of batch.items) {
const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value));
const key = JSON.stringify([format.value, pageRange(item), format.raster ? dpi : 0, archive, backend]); const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey]);
if (item.settingsKey !== key) { if (item.settingsKey !== key) {
item.status = "pending"; item.status = "pending";
item.text = "待转"; item.text = "待转";
@@ -4629,6 +4634,12 @@ async function runBatch() {
} }
} else destination = await window.showDirectoryPicker({ mode: "readwrite" }); } else destination = await window.showDirectoryPicker({ mode: "readwrite" });
if (batch.canceled) return; if (batch.canceled) return;
if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value))) {
await requestLocalFontsBeforeOpen();
}
if (batch.canceled) return;
batch.localFonts = fontManager.localEntries();
batch.fontData = new Map();
batchElements.Status.textContent = "正在准备引擎"; batchElements.Status.textContent = "正在准备引擎";
await startBatchWorker(); await startBatchWorker();
const fonts = await fontManager.files(fontManager.records()); const fonts = await fontManager.files(fontManager.records());
@@ -4642,7 +4653,8 @@ async function runBatch() {
const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase(); const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase();
for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`; for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`;
names.add(outputName()); names.add(outputName());
const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null }; const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, base: unique, credentials: null,
fontNames: [...batch.localFonts.keys()] };
try { try {
for (;;) { for (;;) {
try { try {
@@ -4694,6 +4706,8 @@ async function runBatch() {
batch.worker = null; batch.worker = null;
batch.requests.clear(); batch.requests.clear();
batch.activeID = 0; batch.activeID = 0;
batch.fontData?.clear();
batch.localFonts?.clear();
if (storage && stagingName) { if (storage && stagingName) {
try { await storage.removeEntry(stagingName, { recursive: true }); } try { await storage.removeEntry(stagingName, { recursive: true }); }
catch { batchElements.Status.textContent += " · 临时文件清理失败"; } catch { batchElements.Status.textContent += " · 临时文件清理失败"; }
+14
View File
@@ -396,6 +396,20 @@ export class FontManager {
return `${name}.ttf`; return `${name}.ttf`;
} }
localEntries(fonts = this.catalog) {
const entries = new Map();
const disabled = new Set(this.localFonts.filter(font => !font.enabled).map(font => font.name));
for (const font of fonts) {
if (disabled.has(this.localName(font))) continue;
const names = [this.localName(font), font.postscriptName && `${font.postscriptName}.ttf`,
font.family && `${[font.family, font.style].filter(Boolean).join(" ")}.ttf`];
for (const name of names) {
if (name && !entries.has(name)) entries.set(name, font);
}
}
return entries;
}
canReadLocal() { canReadLocal() {
return typeof window.queryLocalFonts === "function"; return typeof window.queryLocalFonts === "function";
} }
+21 -1
View File
@@ -2,7 +2,14 @@ importScripts("./wasm_exec.js");
let pending = Promise.resolve(); let pending = Promise.resolve();
const operations = new Map(); const operations = new Map();
const fontRequests = new Map();
let fontSequence = 0;
self.onmessage = ({ data }) => { self.onmessage = ({ data }) => {
if (data.type === "font") {
fontRequests.get(data.request)?.(data);
fontRequests.delete(data.request);
return;
}
if (data.type === "cancel") { if (data.type === "cancel") {
operations.get(data.id)?.abort(); operations.get(data.id)?.abort();
return; return;
@@ -96,7 +103,20 @@ async function handleBatchMessage({ id, name, args }) {
finish(done); finish(done);
}, },
(action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)), (action, pageName, done) => boundary(action, pageName).then(() => finish(done), err => finish(done, err)),
done => finish(done), fonts); done => finish(done), fonts, (name, done) => {
if (signal.aborted) { done(null, "", true); return; }
const request = ++fontSequence;
const cancel = () => {
fontRequests.delete(request);
done(null, "", true);
};
signal.addEventListener("abort", cancel, { once: true });
fontRequests.set(request, result => {
signal.removeEventListener("abort", cancel);
done(result.bytes, result.error || "", signal.aborted);
});
self.postMessage({ id, type: "font", request, name });
});
} else { } else {
const [entries, handle] = args; const [entries, handle] = args;
if (handle) output = await handle.createWritable(); if (handle) output = await handle.createWritable();
+1 -1
View File
@@ -9,7 +9,7 @@ require (
github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6
github.com/xiaoqidun/jbig2 v1.1.1 github.com/xiaoqidun/jbig2 v1.1.1
github.com/xiaoqidun/pdfgo v0.0.0-20260926132451-00d165f66ad7 github.com/xiaoqidun/pdfgo v0.0.0-20260926152052-3a4cb2e9266a
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/text v0.42.0 golang.org/x/text v0.42.0
+2 -2
View File
@@ -62,8 +62,8 @@ github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= github.com/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI=
github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ= github.com/xiaoqidun/jbig2 v1.1.1/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
github.com/xiaoqidun/pdfgo v0.0.0-20260926132451-00d165f66ad7 h1:yRhuqMZm146rOe1682qJJoBld8zwNYg3RwvdvWkbwzk= github.com/xiaoqidun/pdfgo v0.0.0-20260926152052-3a4cb2e9266a h1:BT4VQisXVY8OlUdZFE6WTOIktGk8fCQOfkWlSxvL+cc=
github.com/xiaoqidun/pdfgo v0.0.0-20260926132451-00d165f66ad7/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= github.com/xiaoqidun/pdfgo v0.0.0-20260926152052-3a4cb2e9266a/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+32 -4
View File
@@ -398,10 +398,10 @@ func convertPDFDocument(args []js.Value) (any, error) {
} }
// convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 // convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话
// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查及字体 // 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查、字体及按需字体读取
// 返回: any 转换报告, error 转换错误 // 返回: any 转换报告, error 转换错误
func convertFile(args []js.Value) (any, error) { func convertFile(args []js.Value) (any, error) {
if len(args) != 8 { if len(args) != 9 {
return nil, fmt.Errorf("missing conversion arguments") return nil, fmt.Errorf("missing conversion arguments")
} }
settings := args[2] settings := args[2]
@@ -422,9 +422,13 @@ func convertFile(args []js.Value) (any, error) {
return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total)
}, },
} }
if len(fonts) > 0 { fontSources := []fs.FS{ofdgo.NewFontFS(fonts)}
options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(ofdgo.NewFontFS(fonts))) if names := settings.Get("fontNames"); !names.IsUndefined() && names.Length() > 0 {
fontSources = append(fontSources, ofdgo.NewLazyFontFS(stringsFromJS(names), func(name string) ([]byte, error) {
return loadConversionFont(args[8], name)
}))
} }
options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(fontSources...))
if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() { if value := settings.Get("credentials"); !value.IsNull() && !value.IsUndefined() {
credentials, err := credentialsFromJS(value) credentials, err := credentialsFromJS(value)
if err != nil { if err != nil {
@@ -476,6 +480,30 @@ func convertFile(args []js.Value) (any, error) {
return report, nil return report, nil
} }
// loadConversionFont 按需读取浏览器已授权字体,不预读系统字体目录
// 入参: load 浏览器读取函数, name 字体文件名
// 返回: []byte 字体内容, error 读取错误或取消原因
func loadConversionFont(load js.Value, name string) ([]byte, error) {
done := make(chan error, 1)
var data []byte
callback := js.FuncOf(func(this js.Value, args []js.Value) any {
var err error
if args[2].Bool() {
err = context.Canceled
} else if message := args[1].String(); message != "" {
err = fmt.Errorf("%s", message)
} else {
data, err = bytesFromJS(args[0])
}
done <- err
return nil
})
defer callback.Release()
load.Invoke(name, callback)
err := <-done
return data, err
}
// packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 // packFiles 将已经成功转换的文件逐个打包,不重复转换或读取整个文件
// 入参: args 文件列表、分块读取、分块写出、进度及取消检查 // 入参: args 文件列表、分块读取、分块写出、进度及取消检查
// 返回: any 输出格式, error 打包错误 // 返回: any 输出格式, error 打包错误
+8 -9
View File
@@ -783,15 +783,17 @@ func (e *Editor) editorFont(id string) (FontMetrics, error) {
return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("fonts: %w", ErrBackendUnavailable)} return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("fonts: %w", ErrBackendUnavailable)}
} }
var data []byte var data []byte
createdExternal := false var external *Font
for _, resource := range e.resources { for _, resource := range e.resources {
if resource.font != nil && resource.font.ID == id { if resource.font != nil && resource.font.ID == id {
data = resource.data data = resource.data
createdExternal = resource.font.FontFile == "" if resource.font.FontFile == "" {
external = resource.font
}
break break
} }
} }
if data == nil && e.source != nil && !createdExternal { if data == nil && e.source != nil && external == nil {
if e.fontRenderer == nil { if e.fontRenderer == nil {
e.fontRenderer = e.newRenderer(e.source.reader) e.fontRenderer = e.newRenderer(e.source.reader)
} }
@@ -801,12 +803,9 @@ func (e *Editor) editorFont(id string) (FontMetrics, error) {
} }
data = resolved.Data data = resolved.Data
} }
if data == nil && createdExternal { if data == nil && external != nil {
reader, err := e.Reader() reader := &Reader{doc: &Document{}, fontResourcesRead: true, fontCache: map[string]*Font{id: external}}
if err != nil { resolved, err := e.newRenderer(reader).ResolveFont(id, true)
return nil, &EditError{Code: EditFontUnavailable, Err: err}
}
resolved, err := e.newRenderer(reader).ResolveFont(id, false)
if err != nil { if err != nil {
return nil, &EditError{Code: EditFontUnavailable, Err: err} return nil, &EditError{Code: EditFontUnavailable, Err: err}
} }
+63 -4
View File
@@ -21,6 +21,7 @@ import (
"path" "path"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -30,9 +31,10 @@ type FontFile struct {
Data []byte Data []byte
} }
// FontFS 内存字体文件系统 // FontFS 缓存字体数据及名称索引的只读文件系统
type FontFS struct { type FontFS struct {
files map[string][]byte files map[string][]byte
loaders map[string]func() ([]byte, error)
names []string names []string
candidates []fontFileCandidate candidates []fontFileCandidate
} }
@@ -62,6 +64,32 @@ func NewFontFS(fonts []FontFile) *FontFS {
return fsys return fsys
} }
// NewLazyFontFS 创建按需读取的字体文件系统,按文件名匹配,不预读字体内容
// 每个文件的读取结果仅缓存一次;目录条目大小为0,打开后可获取实际大小
// 入参: names 包含字体名称和样式的文件名, load 按原文件名读取独立字体的非nil函数
// 返回: *FontFS 字体文件系统
func NewLazyFontFS(names []string, load func(string) ([]byte, error)) *FontFS {
fsys := &FontFS{files: make(map[string][]byte), loaders: make(map[string]func() ([]byte, error))}
for _, original := range names {
name := cleanFontName(original)
if name == "." || !isFontFileName(name) {
continue
}
if _, exists := fsys.files[name]; exists {
continue
}
fsys.names = append(fsys.names, name)
fsys.files[name] = nil
fsys.loaders[name] = sync.OnceValues(func() ([]byte, error) {
data, err := load(original)
return bytes.Clone(data), err
})
}
sort.Strings(fsys.names)
fsys.candidates = fontFileCandidates(fsys.names, path.Base)
return fsys
}
// Len 获取字体文件数量 // Len 获取字体文件数量
// 返回: int 字体文件数量 // 返回: int 字体文件数量
func (fsys *FontFS) Len() int { func (fsys *FontFS) Len() int {
@@ -85,6 +113,13 @@ func (fsys *FontFS) Open(name string) (fs.File, error) {
if !ok { if !ok {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist} return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
} }
if load := fsys.loaders[strings.ToLower(name)]; load != nil {
var err error
data, err = load()
if err != nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: err}
}
}
return &fontMemFile{ return &fontMemFile{
Reader: bytes.NewReader(data), Reader: bytes.NewReader(data),
info: fontFileInfo{ info: fontFileInfo{
@@ -281,7 +316,9 @@ type fontFileCandidate struct {
face int face int
base string base string
lowerBase string lowerBase string
filename string
normalized string normalized string
names []string
fold string fold string
} }
@@ -293,11 +330,13 @@ func fontFileCandidates(names []string, base func(string) string) []fontFileCand
for _, name := range names { for _, name := range names {
if isFontFileName(name) { if isFontFileName(name) {
baseName := base(name) baseName := base(name)
normalized := fontNormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName)))
files = append(files, fontFileCandidate{ files = append(files, fontFileCandidate{
name: name, name: name,
base: baseName, base: baseName,
lowerBase: strings.ToLower(baseName), lowerBase: strings.ToLower(baseName),
normalized: fontNormalizeName(baseName), filename: normalized,
normalized: normalized,
fold: strings.ToLower(name), fold: strings.ToLower(name),
}) })
} }
@@ -370,7 +409,7 @@ func (m fontPatternMatcher) rankCandidate(file fontFileCandidate) int {
} }
return fontMatchNone return fontMatchNone
} }
name := file.normalized name := m.candidateName(file)
if name == m.stem { if name == m.stem {
return fontMatchExact return fontMatchExact
} }
@@ -401,6 +440,26 @@ func (m fontPatternMatcher) rankCandidate(file fontFileCandidate) int {
return fontMatchNone return fontMatchNone
} }
// candidateName 保留同族内部名称的区分,只有无关联时使用调用方文件别名
// 入参: file 字体文件候选
// 返回: string 用于统一匹配名称与样式的规范名称
func (m fontPatternMatcher) candidateName(file fontFileCandidate) string {
if len(file.names) == 0 || file.base == "" || m.stem == "" {
return file.normalized
}
for _, name := range file.names {
if strings.HasPrefix(name, m.stem) {
return file.normalized
}
for _, alias := range m.aliases {
if strings.HasPrefix(name, alias) {
return file.normalized
}
}
}
return file.filename
}
// styleSuffixNormalized 获取规范字体名称的样式后缀 // styleSuffixNormalized 获取规范字体名称的样式后缀
// 入参: name 规范字体名称 // 入参: name 规范字体名称
// 返回: string 样式后缀 // 返回: string 样式后缀
@@ -441,7 +500,7 @@ func appendFontFileMatch(matches *[]fontFileMatch, seen map[fontFileKey]int, mat
if rank == fontMatchNone { if rank == fontMatchNone {
return return
} }
suffix := matcher.styleSuffixNormalized(file.normalized) suffix := matcher.styleSuffixNormalized(matcher.candidateName(file))
if rank == fontMatchFuzzy { if rank == fontMatchFuzzy {
suffix = matcher.styleSuffixNormalized(fontNormalizeName(file.base)) suffix = matcher.styleSuffixNormalized(fontNormalizeName(file.base))
} }
+7 -3
View File
@@ -33,7 +33,7 @@ type fontNameRecord struct {
Offset uint16 Offset uint16
} }
// appendFontFileNames 使用字体内部名称匹配,缺失时保留文件名 // appendFontFileNames 建立各字体的内部名称与文件别名匹配候选
// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称 // 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称
// 返回: []fontFileCandidate 字体候选列表 // 返回: []fontFileCandidate 字体候选列表
func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate { func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate {
@@ -41,8 +41,12 @@ func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]st
replaced := false replaced := false
for face, names := range faces { for face, names := range faces {
file.face = face file.face = face
for _, name := range names { file.names = make([]string, len(names))
file.normalized = fontNormalizeName(name) for i, name := range names {
file.names[i] = fontNormalizeName(name)
}
for _, name := range file.names {
file.normalized = name
if !replaced { if !replaced {
candidates[index] = file candidates[index] = file
replaced = true replaced = true
+3 -3
View File
@@ -210,12 +210,12 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource {
seen := make(map[fontSourceKey]bool) seen := make(map[fontSourceKey]bool)
for _, dir := range r.fontDirs { for _, dir := range r.fontDirs {
for _, match := range r.matchFontFiles(dir, names, bold, italic) { for _, match := range r.matchFontFiles(dir, names, bold, italic) {
sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)})
} }
} }
for index := range r.fontFS { for index := range r.fontFS {
for _, match := range r.matchFontFS(index, names, bold, italic) { for _, match := range r.matchFontFS(index, names, bold, italic) {
sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)})
} }
} }
sortFontSources(sources) sortFontSources(sources)
@@ -223,7 +223,7 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource {
if canLoadSystemFonts() { if canLoadSystemFonts() {
for _, dir := range systemFontDirs() { for _, dir := range systemFontDirs() {
for _, match := range r.matchFontFiles(dir, names, bold, italic) { for _, match := range r.matchFontFiles(dir, names, bold, italic) {
sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: match.rank == fontMatchExact, priority: min(match.priority, 0)})
} }
} }
for _, name := range fontSystemNames(names...) { for _, name := range fontSystemNames(names...) {
+77 -15
View File
@@ -16,6 +16,7 @@ package ofdgo
import ( import (
"image/color" "image/color"
"math"
"strconv" "strconv"
"strings" "strings"
) )
@@ -31,6 +32,29 @@ func (r *Renderer) ResolveColor(value string, index *int, space string, alpha *i
// 入参: value 颜色值, index 调色板索引, space 颜色空间标识, alpha 透明度 // 入参: value 颜色值, index 调色板索引, space 颜色空间标识, alpha 透明度
// 返回: color.Color 颜色对象 // 返回: color.Color 颜色对象
func (r *Renderer) parseColorWithAlpha(value string, index *int, space string, alpha *int) color.Color { func (r *Renderer) parseColorWithAlpha(value string, index *int, space string, alpha *int) color.Color {
kind, values := r.colorComponents(value, index, space)
var components [4]uint8
for i, value := range values {
components[i] = uint8(math.Round(value * 255))
}
red, green, blue := components[0], components[1], components[2]
switch kind {
case "GRAY":
green, blue = red, red
case "CMYK":
red, green, blue = color.CMYKToRGB(components[0], components[1], components[2], components[3])
}
a := 255
if alpha != nil {
a = clampColor(*alpha)
}
return color.RGBA{R: uint8(int(red) * a / 255), G: uint8(int(green) * a / 255), B: uint8(int(blue) * a / 255), A: uint8(a)}
}
// colorComponents 读取颜色空间及归一化分量,保留源位深供渐变插值
// 入参: value 颜色值, index 调色板索引, space 颜色空间标识
// 返回: string 颜色模型, [4]float64 单位分量
func (r *Renderer) colorComponents(value string, index *int, space string) (string, [4]float64) {
if space == "" && r.Reader.doc != nil { if space == "" && r.Reader.doc != nil {
space = strconv.Itoa(r.Reader.doc.CommonData.DefaultCS) space = strconv.Itoa(r.Reader.doc.CommonData.DefaultCS)
} }
@@ -53,30 +77,19 @@ func (r *Renderer) parseColorWithAlpha(value string, index *int, space string, a
count = 4 count = 4
} }
parts := strings.Fields(value) parts := strings.Fields(value)
var components [4]uint8 var components [4]float64
limit := 1<<bits - 1 limit := 1<<bits - 1
if len(parts) >= count { if len(parts) >= count {
for i, part := range parts[:count] { for i, part := range parts[:count] {
v, err := parseColorComponent(part) v, err := parseColorComponent(part)
if err != nil || v < 0 || limit < v { if err != nil || v < 0 || limit < v {
components = [4]uint8{} components = [4]float64{}
break break
} }
components[i] = uint8((v*255 + limit/2) / limit) components[i] = float64(v) / float64(limit)
} }
} }
red, green, blue := components[0], components[1], components[2] return kind, components
switch kind {
case "GRAY":
green, blue = red, red
case "CMYK":
red, green, blue = color.CMYKToRGB(components[0], components[1], components[2], components[3])
}
a := 255
if alpha != nil {
a = clampColor(*alpha)
}
return color.RGBA{R: uint8(int(red) * a / 255), G: uint8(int(green) * a / 255), B: uint8(int(blue) * a / 255), A: uint8(a)}
} }
// parseColorComponent 解析颜色分量 // parseColorComponent 解析颜色分量
@@ -232,6 +245,55 @@ func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop
return gradient return gradient
} }
// renderGradientStops 在CMYK分量空间插值,将RGB分段误差控制在半个8位灰阶内
// 入参: segments 原始颜色分段, alpha 对象透明度
// 返回: []ColorStop 仅供绘制的颜色节点,不改变原始分段
func (r *Renderer) renderGradientStops(segments []ShdSegment, alpha *int) []ColorStop {
stops := r.GradientStops(segments, alpha)
if len(stops) < 2 {
return stops
}
var result []ColorStop
for i, stop := range stops {
if i > 0 && stops[i-1].Offset < stop.Offset {
a, b := segments[i-1].Color, segments[i].Color
leftKind, left := r.colorComponents(a.Value, a.Index, a.ColorSpace)
rightKind, right := r.colorComponents(b.Value, b.Index, b.ColorSpace)
if leftKind == "CMYK" && rightKind == "CMYK" {
a0, a1 := float64(stops[i-1].Color.A)/255, float64(stop.Color.A)/255
dk, da := right[3]-left[3], a1-a0
bound := 0.0
for c := 0; c < 3; c++ {
dc := right[c] - left[c]
bound = math.Max(bound, 2*math.Abs(dc*dk)+2*math.Abs(da)*(math.Abs(dc)+math.Abs(dk)))
}
steps := int(math.Ceil(math.Sqrt(bound * 255 / 4)))
if steps > 1 && result == nil {
result = append(make([]ColorStop, 0, len(stops)+steps-1), stops[:i]...)
}
for j := 1; j < steps; j++ {
t := float64(j) / float64(steps)
var values [4]float64
for c := range values {
values[c] = left[c]*(1-t) + right[c]*t
}
opacity := a0*(1-t) + a1*t
black := (1 - values[3]) * opacity * 255
color := color.RGBA{R: uint8(math.Round((1 - values[0]) * black)), G: uint8(math.Round((1 - values[1]) * black)), B: uint8(math.Round((1 - values[2]) * black)), A: uint8(math.Round(opacity * 255))}
result = append(result, ColorStop{Offset: stops[i-1].Offset*(1-t) + stop.Offset*t, Color: color})
}
}
}
if result != nil {
result = append(result, stop)
}
}
if result == nil {
return stops
}
return result
}
// gradientPositions 按显式锚点补全渐变分段位置,保留原顺序 // gradientPositions 按显式锚点补全渐变分段位置,保留原顺序
// 入参: segments 渐变分段 // 入参: segments 渐变分段
// 返回: []float64 分段位置 // 返回: []float64 分段位置
+2 -2
View File
@@ -68,7 +68,7 @@ func (r *Renderer) ResolvePaint(fill *FillColor) Paint {
paint := Paint{Kind: PaintSolid, Color: colorToRGBA(base)} paint := Paint{Kind: PaintSolid, Color: colorToRGBA(base)}
if node := fill.AxialShd; node != nil { if node := fill.AxialShd; node != nil {
start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint) start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint)
stops := r.GradientStops(node.Segment, fill.Alpha) stops := r.renderGradientStops(node.Segment, fill.Alpha)
if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 && (!geometryEqual(start[0], end[0]) || !geometryEqual(start[1], end[1])) { if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 && (!geometryEqual(start[0], end[0]) || !geometryEqual(start[1], end[1])) {
paint.Kind = PaintLinear paint.Kind = PaintLinear
paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit} paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit}
@@ -77,7 +77,7 @@ func (r *Renderer) ResolvePaint(fill *FillColor) Paint {
} }
if node := fill.RadialShd; node != nil && node.EndRadius > 0 { if node := fill.RadialShd; node != nil && node.EndRadius > 0 {
start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint) start, end := parseFloats(node.StartPoint), parseFloats(node.EndPoint)
stops := r.GradientStops(node.Segment, fill.Alpha) stops := r.renderGradientStops(node.Segment, fill.Alpha)
if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 { if len(start) >= 2 && len(end) >= 2 && len(stops) != 0 {
paint.Kind = PaintRadial paint.Kind = PaintRadial
paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, StartRadius: node.StartRadius, EndRadius: node.EndRadius, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit, Eccentricity: node.Eccentricity, Angle: node.Angle} paint.Gradient = &Shading{Start: Point{X: start[0], Y: start[1]}, End: Point{X: end[0], Y: end[1]}, StartRadius: node.StartRadius, EndRadius: node.EndRadius, Stops: stops, Extend: node.Extend, MapType: node.MapType, MapUnit: node.MapUnit, Eccentricity: node.Eccentricity, Angle: node.Angle}
+21 -16
View File
@@ -164,14 +164,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI
importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path) importer.maskClips = make(map[*pdfgo.SoftMask]pdfgo.Path)
importer.compositeCache = nil importer.compositeCache = nil
importer.clipTexts = make(map[*pdfgo.TextClip]TextObject) importer.clipTexts = make(map[*pdfgo.TextClip]TextObject)
visitor := pdfgo.Visitor{Path: importer.path, Text: importer.text, Image: importer.image, Warning: importer.warning} if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil {
visitor.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error {
if mark.Page && mark.ColorSpace != nil && mark.ColorSpace.Model == "DeviceCMYK" && !mark.ColorSpace.Calibrated() {
return importer.compositePage(mark.ColorSpace, walk)
}
return importer.group(mark, walk, visitor)
}
if err := reader.WalkPage(ctx, page, visitor); err != nil {
return fmt.Errorf("import PDF page %d: %w", index+1, err) return fmt.Errorf("import PDF page %d: %w", index+1, err)
} }
if err := importer.flushPath(); err != nil { if err := importer.flushPath(); err != nil {
@@ -466,9 +459,6 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
if !ok { if !ok {
return fmt.Errorf("invalid PDF stroke matrix") return fmt.Errorf("invalid PDF stroke matrix")
} }
if mark.Style.Fill.Radial != nil || mark.Style.Stroke.Radial != nil {
return &pdfgo.UnsupportedError{Feature: "radial gradient with anisotropic path stroke"}
}
drawing.matrix, origin, scale = inverse, Box{}, 1 drawing.matrix, origin, scale = inverse, Box{}, 1
m := p.matrix.Mul(mark.StrokeMatrix) m := p.matrix.Mul(mark.StrokeMatrix)
marginScale = math.Max(math.Hypot(m[0], m[2]), math.Hypot(m[1], m[3])) marginScale = math.Max(math.Hypot(m[0], m[2]), math.Hypot(m[1], m[3]))
@@ -531,6 +521,9 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
// 入参: paint PDF画刷, box 对象边界 // 入参: paint PDF画刷, box 对象边界
// 返回: *FillColor OFD颜色或渐变, error 图案转换错误 // 返回: *FillColor OFD颜色或渐变, error 图案转换错误
func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) { func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) {
if paint.Mesh != nil {
return nil, &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"}
}
color := p.color(paint) color := p.color(paint)
if paint.Tiling != nil { if paint.Tiling != nil {
pattern, err := p.tilingPattern(paint.Tiling, paint) pattern, err := p.tilingPattern(paint.Tiling, paint)
@@ -542,8 +535,20 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error)
} }
if paint.Radial != nil { if paint.Radial != nil {
gradient := paint.Radial gradient := paint.Radial
start, end := p.matrix.Apply(gradient.Start), p.matrix.Apply(gradient.End) matrix := p.matrix
scale := math.Hypot(p.matrix[0], p.matrix[1]) if gradient.Matrix != (pdfgo.Matrix{}) {
matrix = matrix.Mul(gradient.Matrix)
}
start, end := matrix.Apply(gradient.Start), matrix.Apply(gradient.End)
xx, yy, xy := matrix[0]*matrix[0]+matrix[2]*matrix[2], matrix[1]*matrix[1]+matrix[3]*matrix[3], matrix[0]*matrix[1]+matrix[2]*matrix[3]
major := (xx + yy + math.Hypot(xx-yy, 2*xy)) / 2
determinant := matrix[0]*matrix[3] - matrix[1]*matrix[2]
if major <= 0 || determinant == 0 {
return nil, fmt.Errorf("invalid PDF radial gradient matrix")
}
scale := math.Sqrt(major)
eccentricity := math.Sqrt(math.Max(0, 1-determinant*determinant/(major*major)))
angle := math.Atan2(2*xy, xx-yy) * 90 / math.Pi
extend := 0 extend := 0
if gradient.Extend[0] { if gradient.Extend[0] {
extend |= 1 extend |= 1
@@ -551,9 +556,9 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error)
if gradient.Extend[1] { if gradient.Extend[1] {
extend |= 2 extend |= 2
} }
shading := &RadialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), StartRadius: gradient.StartRadius * scale, EndRadius: gradient.EndRadius * scale, Extend: strconv.Itoa(extend)} shading := &RadialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), StartRadius: gradient.StartRadius * scale, EndRadius: gradient.EndRadius * scale, Eccentricity: eccentricity, Angle: angle, Extend: strconv.Itoa(extend)}
var err error var err error
shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -587,7 +592,7 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error)
} }
shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)} shading := &AxialShd{StartPoint: pdfNumbers(start.X-box.X, start.Y-box.Y), EndPoint: pdfNumbers(end.X-box.X, end.Y-box.Y), Extend: strconv.Itoa(extend)}
var err error var err error
shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+33 -3
View File
@@ -37,6 +37,7 @@ type pdfCompositePixel struct {
values [4]float64 values [4]float64
alpha float64 alpha float64
effect float64 effect float64
shape float64
} }
// pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元 // pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元
@@ -47,6 +48,7 @@ type pdfCompositor struct {
inverse pdfgo.Matrix inverse pdfgo.Matrix
cache *pdfCompositeCache cache *pdfCompositeCache
masks map[*pdfgo.SoftMask][]float64 masks map[*pdfgo.SoftMask][]float64
meshes map[pdfMeshKey][]pdfMeshPixel
} }
// pdfCompositeKey 区分图元的填充与描边几何 // pdfCompositeKey 区分图元的填充与描边几何
@@ -68,13 +70,14 @@ type pdfCompositeCache struct {
geometry map[pdfCompositeKey]pdfCompositeGeometry geometry map[pdfCompositeKey]pdfCompositeGeometry
images map[*pdfgo.Image]*pdfgo.ImageComponents images map[*pdfgo.Image]*pdfgo.ImageComponents
masks map[*pdfgo.SoftMask][]pdfCompositeNode masks map[*pdfgo.SoftMask][]pdfCompositeNode
meshes map[*pdfgo.MeshGradient][]pdfMeshTriangle
} }
// compositingCache 按需创建单页合成缓存,页面切换时由导入器释放 // compositingCache 按需创建单页合成缓存,页面切换时由导入器释放
// 返回: *pdfCompositeCache 当前页面缓存 // 返回: *pdfCompositeCache 当前页面缓存
func (p *pdfImporter) compositingCache() *pdfCompositeCache { func (p *pdfImporter) compositingCache() *pdfCompositeCache {
if p.compositeCache == nil { if p.compositeCache == nil {
p.compositeCache = &pdfCompositeCache{geometry: map[pdfCompositeKey]pdfCompositeGeometry{}, images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}} p.compositeCache = &pdfCompositeCache{geometry: map[pdfCompositeKey]pdfCompositeGeometry{}, images: map[*pdfgo.Image]*pdfgo.ImageComponents{}, masks: map[*pdfgo.SoftMask][]pdfCompositeNode{}, meshes: map[*pdfgo.MeshGradient][]pdfMeshTriangle{}}
} }
return p.compositeCache return p.compositeCache
} }
@@ -162,8 +165,17 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis
if err != nil { if err != nil {
return err return err
} }
if len(nodes) == 1 && nodes[0].group != nil && nodes[0].group.Page {
space = nodes[0].group.ColorSpace
if !nodes[0].group.Knockout {
nodes = nodes[0].children
}
}
if space == nil {
space = &pdfgo.ColorSpace{Model: "DeviceRGB"}
}
for i, node := range nodes { for i, node := range nodes {
if node.opaque(space) { if node.opaque(space) || space.SRGBEquivalent() && node.direct() {
if err := node.emit(p.visitor()); err != nil { if err := node.emit(p.visitor()); err != nil {
return fmt.Errorf("convert graphic %d: %w", i+1, err) return fmt.Errorf("convert graphic %d: %w", i+1, err)
} }
@@ -188,7 +200,7 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis
func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool { func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool {
if n.group != nil { if n.group != nil {
g := n.group g := n.group
if g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.Equal(space) { if g.Knockout || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.Equal(space) {
return false return false
} }
for _, child := range n.children { for _, child := range n.children {
@@ -214,6 +226,24 @@ func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool {
return true return true
} }
// direct 检查sRGB图元能否使用标准OFD透明度而无需背景合成
// 返回: bool 是否可直接转换
func (n pdfCompositeNode) direct() bool {
if g := n.group; g != nil {
if g.Knockout || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.SRGBEquivalent() {
return false
}
for _, child := range n.children {
if !child.direct() {
return false
}
}
return true
}
s, fill, stroke := n.style()
return pdfNormalBlend(s.BlendMode) && !(fill && s.FillOverprint && pdfOverprintNeedsSeparation(s.Fill) || stroke && s.StrokeOverprint && pdfOverprintNeedsSeparation(s.Stroke))
}
// style 获取图元实际使用的图形状态 // style 获取图元实际使用的图形状态
// 返回: pdfgo.Style 图形状态, bool 是否填充, bool 是否描边 // 返回: pdfgo.Style 图形状态, bool 是否填充, bool 是否描边
func (n pdfCompositeNode) style() (pdfgo.Style, bool, bool) { func (n pdfCompositeNode) style() (pdfgo.Style, bool, bool) {
+69 -7
View File
@@ -60,6 +60,7 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix
result = append([]pdfCompositePixel(nil), pixels...) result = append([]pdfCompositePixel(nil), pixels...)
for i := range result { for i := range result {
result[i].effect = 0 result[i].effect = 0
result[i].shape = 0
} }
} }
} }
@@ -99,7 +100,14 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
style, _, _ := node.style() style, _, _ := node.style()
compatible := overprint && style.OverprintMode == 1 && node.image == nil && space.Model == "DeviceCMYK" && !space.Calibrated() && (paint.CMYK != nil || paint.Space != nil && paint.Space.Model == "DeviceCMYK" && !paint.Space.Calibrated()) compatible := overprint && style.OverprintMode == 1 && node.image == nil && space.Model == "DeviceCMYK" && !space.Calibrated() && (paint.CMYK != nil || paint.Space != nil && paint.Space.Model == "DeviceCMYK" && !paint.Space.Calibrated())
step := 25.4 / c.importer.rasterDPI step := 25.4 / c.importer.rasterDPI
uniform := node.image == nil && paint.Axial == nil && paint.Radial == nil && paint.Tiling == nil uniform := node.image == nil && paint.Axial == nil && paint.Radial == nil && paint.Tiling == nil && paint.Mesh == nil
var mesh []pdfMeshPixel
if paint.Mesh != nil {
mesh, err = c.mesh(paint.Mesh, space)
if err != nil {
return err
}
}
var uniformValues [4]float64 var uniformValues [4]float64
uniformReady := false uniformReady := false
for y := 0; y < c.height; y++ { for y := 0; y < c.height; y++ {
@@ -117,7 +125,10 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
opacity *= mask[index] opacity *= mask[index]
} }
var values [4]float64 var values [4]float64
if uniform { if mesh != nil {
values = mesh[index].values
shape *= mesh[index].shape
} else if uniform {
if !uniformReady { if !uniformReady {
uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent) uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent)
uniformReady = true uniformReady = true
@@ -140,10 +151,10 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
if err != nil { if err != nil {
return err return err
} }
if style.AlphaIsShape {
shape, opacity = shape*opacity, 1
}
if initial != nil { if initial != nil {
if style.AlphaIsShape {
shape, opacity = shape*opacity, 1
}
pixel := initial[index] pixel := initial[index]
if err := pdfCompositePaintOver(&pixel, values, opacity, space, mode, compatible); err != nil { if err := pdfCompositePaintOver(&pixel, values, opacity, space, mode, compatible); err != nil {
return err return err
@@ -152,6 +163,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf
} else if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible); err != nil { } else if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible); err != nil {
return err return err
} }
pixels[index].shape += shape * (1 - pixels[index].shape)
} }
} }
return nil return nil
@@ -190,21 +202,71 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi
} }
} }
result := append([]pdfCompositePixel(nil), initial...) result := append([]pdfCompositePixel(nil), initial...)
if err := c.draw(node.children, result, space); err != nil { if g.Knockout {
return err if err := c.drawKnockout(node.children, initial, result, space); err != nil {
return err
}
} else {
if err := c.draw(node.children, result, space); err != nil {
return err
}
} }
mask, err := c.mask(g.SoftMask, parent) mask, err := c.mask(g.SoftMask, parent)
if err != nil { if err != nil {
return err return err
} }
if g.AlphaIsShape {
for i := range result {
result[i].shape *= g.Alpha
if mask != nil {
result[i].shape *= mask[i]
}
}
}
return pdfCompositeGroup(pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode) return pdfCompositeGroup(pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode)
} }
// drawKnockout 将每个对象与组初始背景合成,再按对象形状替换先前对象
// 入参: nodes 组图元, initial 初始背景, pixels 组输出, space 混合空间
// 返回: error 图元或合成错误
func (c *pdfCompositor) drawKnockout(nodes []pdfCompositeNode, initial, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error {
candidate := make([]pdfCompositePixel, len(initial))
for _, node := range nodes {
copy(candidate, initial)
if err := c.draw([]pdfCompositeNode{node}, candidate, space); err != nil {
return err
}
for i, source := range candidate {
pdfCompositeKnockout(&pixels[i], initial[i], source)
}
}
return nil
}
// pdfCompositeKnockout 移除未覆盖区域的初始背景贡献,保留形状与不透明度差异
// 入参: target 累计组结果, initial 初始背景, source 单个对象合成结果
func pdfCompositeKnockout(target *pdfCompositePixel, initial, source pdfCompositePixel) {
f := source.shape
if f == 0 {
return
}
alpha := target.alpha*(1-f) + source.alpha - initial.alpha*(1-f)
if alpha > 0 {
for j := range target.values {
target.values[j] = math.Max(0, math.Min(1, (target.values[j]*target.alpha*(1-f)+source.values[j]*source.alpha-initial.values[j]*initial.alpha*(1-f))/alpha))
}
}
target.alpha = math.Max(0, math.Min(1, alpha))
target.effect = target.effect*(1-f) + source.effect
target.shape += f * (1 - target.shape)
}
// pdfCompositeGroup 移除组初始背景贡献后,将组结果合成到父空间 // pdfCompositeGroup 移除组初始背景贡献后,将组结果合成到父空间
// 入参: pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式 // 入参: pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式
// 返回: error 颜色或混合错误 // 返回: error 颜色或混合错误
func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode pdfgo.Name) error { func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode pdfgo.Name) error {
for i, pixel := range result { for i, pixel := range result {
pixels[i].shape += pixel.shape * (1 - pixels[i].shape)
if pixel.effect == 0 { if pixel.effect == 0 {
continue continue
} }
+4 -1
View File
@@ -39,6 +39,9 @@ type pdfPaintRegion struct {
// 入参: mark 组边界状态, walk 组内容访问函数, visitor 页面访问器 // 入参: mark 组边界状态, walk 组内容访问函数, visitor 页面访问器
// 返回: error 不支持的组内容或转换错误 // 返回: error 不支持的组内容或转换错误
func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error, visitor pdfgo.Visitor) error { func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error, visitor pdfgo.Visitor) error {
if mark.Knockout {
return &pdfgo.UnsupportedError{Feature: "knockout group outside page compositing"}
}
if mark.BlendMode != "" && mark.BlendMode != "Normal" && mark.BlendMode != "Compatible" { if mark.BlendMode != "" && mark.BlendMode != "Normal" && mark.BlendMode != "Compatible" {
return &pdfgo.UnsupportedError{Feature: "group blend mode " + string(mark.BlendMode)} return &pdfgo.UnsupportedError{Feature: "group blend mode " + string(mark.BlendMode)}
} }
@@ -152,7 +155,7 @@ func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor)
if stroke { if stroke {
color, overprint = path.Style.Stroke, path.Style.StrokeOverprint color, overprint = path.Style.Stroke, path.Style.StrokeOverprint
} }
if color.Tiling != nil || color.Axial != nil || color.Radial != nil || overprint && pdfOverprintNeedsSeparation(color) { if color.Tiling != nil || color.Axial != nil || color.Radial != nil || color.Mesh != nil || overprint && pdfOverprintNeedsSeparation(color) {
return fmt.Errorf("%w: patterned or overprinted group content", errPDFGroupRaster) return fmt.Errorf("%w: patterned or overprinted group content", errPDFGroupRaster)
} }
if color.Alpha == 0 { if color.Alpha == 0 {
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"fmt"
"math"
"github.com/xiaoqidun/pdfgo"
)
// pdfMeshVertex 保存局部毫米坐标及原曲面参数
type pdfMeshVertex struct {
point pdfgo.Point
u, v float64
}
// pdfMeshTriangle 保存按输出精度细分的曲面网格
type pdfMeshTriangle struct {
vertices [3]pdfMeshVertex
patch int
}
// pdfMeshKey 区分网格及其输出混合空间
type pdfMeshKey struct {
mesh *pdfgo.MeshGradient
space *pdfgo.ColorSpace
}
// pdfMeshPixel 保存抗锯齿覆盖率与源空间合成分量
type pdfMeshPixel struct {
values [4]float64
shape float64
}
// pdfMeshSample 保存采样点参数,patch为一基编号,零表示未覆盖
type pdfMeshSample struct {
u, v float64
patch int
}
// meshTriangles 按曲面二阶导数界细分,将几何误差控制在输出像素的十六分之一内
// 入参: mesh 原始曲面
// 返回: []pdfMeshTriangle 细分结果, error 取消或坐标错误
func (p *pdfImporter) meshTriangles(mesh *pdfgo.MeshGradient) ([]pdfMeshTriangle, error) {
matrix := p.matrix.Mul(mesh.Matrix)
var triangles []pdfMeshTriangle
for index, patch := range mesh.Patches {
for i := range patch.Points {
for j := range patch.Points[i] {
patch.Points[i][j] = matrix.Apply(patch.Points[i][j])
}
}
var duu, dvv, duv float64
for i := 0; i < 4; i++ {
for j := 0; j < 4; j++ {
a := patch.Points[i][j]
if i < 2 {
b, c := patch.Points[i+1][j], patch.Points[i+2][j]
duu = math.Max(duu, 6*math.Hypot(c.X-2*b.X+a.X, c.Y-2*b.Y+a.Y))
}
if j < 2 {
b, c := patch.Points[i][j+1], patch.Points[i][j+2]
dvv = math.Max(dvv, 6*math.Hypot(c.X-2*b.X+a.X, c.Y-2*b.Y+a.Y))
}
if i < 3 && j < 3 {
b, c, d := patch.Points[i+1][j], patch.Points[i][j+1], patch.Points[i+1][j+1]
duv = math.Max(duv, 9*math.Hypot(d.X-b.X-c.X+a.X, d.Y-b.Y-c.Y+a.Y))
}
}
}
steps := math.Max(1, math.Ceil(math.Sqrt(8*(duu+dvv+2*duv)*p.rasterDPI/25.4)))
if !finite(steps) || steps >= math.Sqrt(float64(int(^uint(0)>>1)/2)) {
return nil, fmt.Errorf("mesh subdivision exceeds platform integer range")
}
n := int(steps)
row := make([]pdfMeshVertex, n+1)
for i := range row {
u := float64(i) / steps
row[i] = pdfMeshVertex{patch.PointAt(u, 0), u, 0}
}
for j := 1; j <= n; j++ {
if err := p.ctx.Err(); err != nil {
return nil, err
}
v := float64(j) / steps
left := pdfMeshVertex{patch.PointAt(0, v), 0, v}
for i := 1; i <= n; i++ {
u := float64(i) / steps
right := pdfMeshVertex{patch.PointAt(u, v), u, v}
a, b := row[i-1], row[i]
triangles = append(triangles, pdfMeshTriangle{[3]pdfMeshVertex{a, b, left}, index}, pdfMeshTriangle{[3]pdfMeshVertex{b, right, left}, index})
row[i-1], left = left, right
}
row[n] = left
}
}
return triangles, nil
}
// mesh 对当前图块执行四倍超采样,曲面重叠按后补片及较大v、u取值
// 入参: mesh 原曲面, space 输出混合空间
// 返回: []pdfMeshPixel 颜色及覆盖, error 几何或颜色错误
func (c *pdfCompositor) mesh(mesh *pdfgo.MeshGradient, space *pdfgo.ColorSpace) ([]pdfMeshPixel, error) {
key := pdfMeshKey{mesh, space}
if pixels, ok := c.meshes[key]; ok {
return pixels, nil
}
triangles, ok := c.cache.meshes[mesh]
if !ok {
var err error
triangles, err = c.importer.meshTriangles(mesh)
if err != nil {
return nil, err
}
if c.cache.meshes == nil {
c.cache.meshes = map[*pdfgo.MeshGradient][]pdfMeshTriangle{}
}
c.cache.meshes[mesh] = triangles
}
const samples = 4
w, h := c.width*samples, c.height*samples
grid := make([]pdfMeshSample, w*h)
scale := c.importer.rasterDPI / 25.4 * samples
for _, triangle := range triangles {
if err := c.importer.ctx.Err(); err != nil {
return nil, err
}
vertices := triangle.vertices
for i := range vertices {
vertices[i].point.X = (vertices[i].point.X - c.box.X) * scale
vertices[i].point.Y = (vertices[i].point.Y - c.box.Y) * scale
}
a, b, d := vertices[0].point, vertices[1].point, vertices[2].point
denominator := (b.Y-d.Y)*(a.X-d.X) + (d.X-b.X)*(a.Y-d.Y)
if denominator == 0 {
continue
}
minX, maxX := math.Min(a.X, math.Min(b.X, d.X)), math.Max(a.X, math.Max(b.X, d.X))
minY, maxY := math.Min(a.Y, math.Min(b.Y, d.Y)), math.Max(a.Y, math.Max(b.Y, d.Y))
if maxX < 0 || maxY < 0 || minX >= float64(w) || minY >= float64(h) {
continue
}
endX := int(math.Min(float64(w), math.Ceil(maxX)))
endY := int(math.Min(float64(h), math.Ceil(maxY)))
for y := int(math.Max(0, math.Floor(minY))); y < endY; y++ {
for x := int(math.Max(0, math.Floor(minX))); x < endX; x++ {
px, py := float64(x)+.5, float64(y)+.5
l0 := ((b.Y-d.Y)*(px-d.X) + (d.X-b.X)*(py-d.Y)) / denominator
l1 := ((d.Y-a.Y)*(px-d.X) + (a.X-d.X)*(py-d.Y)) / denominator
l2 := 1 - l0 - l1
if l0 < -1e-10 || l1 < -1e-10 || l2 < -1e-10 {
continue
}
u := math.Max(0, math.Min(1, l0*vertices[0].u+l1*vertices[1].u+l2*vertices[2].u))
v := math.Max(0, math.Min(1, l0*vertices[0].v+l1*vertices[1].v+l2*vertices[2].v))
current := &grid[y*w+x]
if triangle.patch+1 > current.patch || triangle.patch+1 == current.patch && (v > current.v || v == current.v && u > current.u) {
*current = pdfMeshSample{u, v, triangle.patch + 1}
}
}
}
}
pixels := make([]pdfMeshPixel, c.width*c.height)
for y := 0; y < h; y++ {
if err := c.importer.ctx.Err(); err != nil {
return nil, err
}
for x := 0; x < w; x++ {
sample := grid[y*w+x]
if sample.patch == 0 {
continue
}
values, err := mesh.ValuesAt(sample.patch-1, sample.u, sample.v)
if err != nil {
return nil, err
}
values, err = space.Convert(values[:mesh.Space.Components()], mesh.Space, mesh.Intent)
if err != nil {
return nil, err
}
pixel := &pixels[(y/samples)*c.width+x/samples]
for i, value := range values {
pixel.values[i] += value
}
pixel.shape++
}
}
for i := range pixels {
pixel := &pixels[i]
if pixel.shape == 0 {
continue
}
for j := range pixel.values {
pixel.values[j] /= pixel.shape
}
pixel.shape /= samples * samples
}
if c.meshes == nil {
c.meshes = map[pdfMeshKey][]pdfMeshPixel{}
}
c.meshes[key] = pixels
return pixels, nil
}
+67 -48
View File
@@ -16,72 +16,61 @@ package ofdgo
import ( import (
"math" "math"
"slices"
"github.com/xiaoqidun/pdfgo" "github.com/xiaoqidun/pdfgo"
) )
// pdfGradientSegments 将线性源颜色及设备四色的截断点表达为OFD分段 // gradientSegments 保留线性源颜色的设备分量,校准颜色经源空间转换
// 入参: stops 源颜色分段, space 源颜色空间 // 入参: stops 源颜色分段, space 源颜色空间
// 返回: []ShdSegment 等价RGB分段, error 不可线性表达的变换 // 返回: []ShdSegment OFD颜色分段, error 不可线性表达的变换
func pdfGradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) ([]ShdSegment, error) { func (p *pdfImporter) gradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) ([]ShdSegment, error) {
if len(stops) == 0 { if err := pdfGradientStopsError(stops, space); err != nil {
return nil, &pdfgo.UnsupportedError{Feature: "nonlinear gradient conversion"} return nil, err
} }
calibrated := space != nil && space.Calibrated() && !space.SRGBEquivalent()
cmyk := space != nil && space.Model == "DeviceCMYK" && !space.Calibrated() cmyk := space != nil && space.Model == "DeviceCMYK" && !space.Calibrated()
segments := make([]ShdSegment, 0, len(stops)) segments := make([]ShdSegment, len(stops))
appendStop := func(position float64, rgb [3]float64) { for i, stop := range stops {
segments = append(segments, ShdSegment{Position: position, Color: ShdColor{Value: pdfNumbers(math.Round(rgb[0]*255), math.Round(rgb[1]*255), math.Round(rgb[2]*255))}}) paint := pdfgo.Paint{RGB: stop.RGB, Alpha: 1}
} if cmyk {
for i, b := range stops { paint.CMYK = &stop.Values
if i > 0 {
a := stops[i-1]
if a.Position != b.Position && a.Values != b.Values {
if calibrated {
return nil, &pdfgo.UnsupportedError{Feature: "nonlinear ICC gradient conversion"}
}
if cmyk {
var crossings []float64
for c := 0; c < 3; c++ {
start, end := a.Values[c]+a.Values[3], b.Values[c]+b.Values[3]
if start != end {
if t := (1 - start) / (end - start); t > 0 && t < 1 {
crossings = append(crossings, t)
}
}
}
slices.Sort(crossings)
for _, t := range slices.Compact(crossings) {
var values [4]float64
for c := range values {
values[c] = a.Values[c]*(1-t) + b.Values[c]*t
}
rgb, err := space.RGB(values[:], "RelativeColorimetric")
if err != nil {
return nil, err
}
appendStop(a.Position+(b.Position-a.Position)*t, rgb)
}
}
}
} }
appendStop(b.Position, b.RGB) color := p.color(paint)
segments[i] = ShdSegment{Position: stop.Position, Color: ShdColor{Value: color.Value, ColorSpace: color.ColorSpace}}
} }
return segments, nil return segments, nil
} }
// pdfGradientStopsError 检查源颜色分段是否可由OFD基本颜色表达
// 入参: stops 源颜色分段, space 源颜色空间
// 返回: error 不可线性表达的变换
func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) error {
if len(stops) == 0 {
return &pdfgo.UnsupportedError{Feature: "nonlinear gradient conversion"}
}
calibrated := space != nil && space.Calibrated() && !space.SRGBEquivalent()
if calibrated {
for i := 1; i < len(stops); i++ {
a, b := stops[i-1], stops[i]
if a.Position != b.Position && a.Values != b.Values {
return &pdfgo.UnsupportedError{Feature: "nonlinear ICC gradient conversion"}
}
}
}
return nil
}
// pdfGradientError 检查渐变能否精确映射为OFD线性分段 // pdfGradientError 检查渐变能否精确映射为OFD线性分段
// 入参: paint 画刷 // 入参: paint 画刷
// 返回: error 不可等价表达的渐变 // 返回: error 不可等价表达的渐变
func pdfGradientError(paint pdfgo.Paint) error { func pdfGradientError(paint pdfgo.Paint) error {
if paint.Mesh != nil {
return &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"}
}
if paint.Axial != nil { if paint.Axial != nil {
_, err := pdfGradientSegments(paint.Axial.Stops, paint.Axial.Space) return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space)
return err
} }
if paint.Radial != nil { if paint.Radial != nil {
_, err := pdfGradientSegments(paint.Radial.Stops, paint.Radial.Space) return pdfGradientStopsError(paint.Radial.Stops, paint.Radial.Space)
return err
} }
return nil return nil
} }
@@ -90,7 +79,30 @@ func pdfGradientError(paint pdfgo.Paint) error {
// 入参: mark 路径绘制信息 // 入参: mark 路径绘制信息
// 返回: error 几何或着色错误 // 返回: error 几何或着色错误
func (p *pdfImporter) gradientPath(mark pdfgo.PathMark) error { func (p *pdfImporter) gradientPath(mark pdfgo.PathMark) error {
return p.compositeRegion(nil, pdfCompositeNode{path: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) cmyk := true
for i, paint := range []pdfgo.Paint{mark.Style.Fill, mark.Style.Stroke} {
if i == 0 && !mark.Fill || i == 1 && !mark.Stroke {
continue
}
space := paint.Space
switch {
case paint.Axial != nil:
space = paint.Axial.Space
case paint.Radial != nil:
space = paint.Radial.Space
case paint.Mesh != nil:
space = paint.Mesh.Space
}
if paint.CMYK == nil && (space == nil || space.Model != "DeviceCMYK" || space.Calibrated()) {
cmyk = false
break
}
}
space := &pdfgo.ColorSpace{Model: "DeviceRGB"}
if cmyk {
space.Model = "DeviceCMYK"
}
return p.compositeRegion(nil, pdfCompositeNode{path: &mark}, space, false)
} }
// pdfShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色 // pdfShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色
@@ -103,6 +115,13 @@ func pdfShadingPosition(paint pdfgo.Paint, point pdfgo.Point) (float64, bool) {
return t, finite(t) && (t >= 0 || g.Extend[0]) && (t <= 1 || g.Extend[1]) return t, finite(t) && (t >= 0 || g.Extend[0]) && (t <= 1 || g.Extend[1])
} }
g := paint.Radial g := paint.Radial
if g.Matrix != (pdfgo.Matrix{}) {
inverse, ok := g.Matrix.Inverse()
if !ok {
return 0, false
}
point = inverse.Apply(point)
}
dx, dy, dr := g.End.X-g.Start.X, g.End.Y-g.Start.Y, g.EndRadius-g.StartRadius dx, dy, dr := g.End.X-g.Start.X, g.End.Y-g.Start.Y, g.EndRadius-g.StartRadius
px, py := point.X-g.Start.X, point.Y-g.Start.Y px, py := point.X-g.Start.X, point.Y-g.Start.Y
a, b, c := dx*dx+dy*dy-dr*dr, -2*(px*dx+py*dy+g.StartRadius*dr), px*px+py*py-g.StartRadius*g.StartRadius a, b, c := dx*dx+dy*dy-dr*dr, -2*(px*dx+py*dy+g.StartRadius*dr), px*px+py*py-g.StartRadius*g.StartRadius