From 98d9effc8cdef1c45dd6812806538bf56a612ac4 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 27 Sep 2026 08:03:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=BD=AC=E6=8D=A2=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=BD=AC=E6=8D=A2=E6=B8=B2=E6=9F=93=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AD=97=E4=BD=93=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 50 ++++++--- assets/webui/ofdgo_font.js | 14 +++ assets/webui/ofdgo_wasm.js | 22 +++- go.mod | 2 +- go.sum | 4 +- internal/webui/wasm.go | 36 +++++- ofdgo_editor_open.go | 17 ++- ofdgo_font_fs.go | 67 ++++++++++- ofdgo_font_name.go | 10 +- ofdgo_font_source.go | 6 +- ofdgo_paint.go | 92 ++++++++++++--- ofdgo_paint_model.go | 4 +- ofdgo_pdf.go | 37 +++--- ofdgo_pdf_composite.go | 36 +++++- ofdgo_pdf_composite_draw.go | 76 +++++++++++-- ofdgo_pdf_group.go | 5 +- ofdgo_pdf_mesh.go | 216 ++++++++++++++++++++++++++++++++++++ ofdgo_pdf_shading.go | 115 +++++++++++-------- 18 files changed, 672 insertions(+), 137 deletions(-) create mode 100644 ofdgo_pdf_mesh.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 046c641..b376780 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -3575,19 +3575,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { } async function selectLocalFonts(fonts) { - const available = new Map(); - 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 available = fontManager.localEntries(fonts); const matched = await callWASM("ofdgoMatchFontFiles", [...available.keys()]); return [...new Set(matched.map((name) => available.get(name)))]; } @@ -4502,11 +4490,15 @@ function updateBatchControls() { batchElements.Cancel.disabled = batch.canceled; batchElements.Close.textContent = batch.running ? "收起" : "关闭"; 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 = !formats.some(value => batch.formats.find(format => format.value === value)?.raster); + batchElements.DPIRow.hidden = !(batch.items.length ? batch.items : [null]).some(batchUsesDPI); 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) { item.status = status; item.text = text; @@ -4558,12 +4550,24 @@ function startBatchWorker() { }; worker.onerror = event => fail(new Error(event.message || "转换引擎异常")); worker.onmessageerror = () => fail(new Error("转换数据传递失败")); - worker.onmessage = ({ data }) => { + worker.onmessage = async ({ data }) => { if (data.type === "ready") { resolve(); return; } if (data.type === "exit") { fail(new Error(data.error)); return; } if (data.type === "progress") { batchElements.Status.textContent = data.text; return; } const request = batch.requests.get(data.id); 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 === "conversion") { request.onProgress?.(data); return; } batch.requests.delete(data.id); @@ -4593,9 +4597,10 @@ async function runBatch() { const dpi = Number(batchElements.DPI.value); const archive = batchElements.Destination.value === "archive"; 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) { 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) { item.status = "pending"; item.text = "待转"; @@ -4629,6 +4634,12 @@ async function runBatch() { } } else destination = await window.showDirectoryPicker({ mode: "readwrite" }); 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 = "正在准备引擎"; await startBatchWorker(); const fonts = await fontManager.files(fontManager.records()); @@ -4642,7 +4653,8 @@ async function runBatch() { const outputName = () => (format.paged ? unique : `${unique}.${format.extension}`).toLowerCase(); for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`; 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 { for (;;) { try { @@ -4694,6 +4706,8 @@ async function runBatch() { batch.worker = null; batch.requests.clear(); batch.activeID = 0; + batch.fontData?.clear(); + batch.localFonts?.clear(); if (storage && stagingName) { try { await storage.removeEntry(stagingName, { recursive: true }); } catch { batchElements.Status.textContent += " · 临时文件清理失败"; } diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 62f6e94..d13a73e 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -396,6 +396,20 @@ export class FontManager { 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() { return typeof window.queryLocalFonts === "function"; } diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index 033d6ca..677d46d 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -2,7 +2,14 @@ importScripts("./wasm_exec.js"); let pending = Promise.resolve(); const operations = new Map(); +const fontRequests = new Map(); +let fontSequence = 0; self.onmessage = ({ data }) => { + if (data.type === "font") { + fontRequests.get(data.request)?.(data); + fontRequests.delete(data.request); + return; + } if (data.type === "cancel") { operations.get(data.id)?.abort(); return; @@ -96,7 +103,20 @@ async function handleBatchMessage({ id, name, args }) { finish(done); }, (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 { const [entries, handle] = args; if (handle) output = await handle.createWritable(); diff --git a/go.mod b/go.mod index bb0629f..c445772 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 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/image v0.46.0 golang.org/x/text v0.42.0 diff --git a/go.sum b/go.sum index c0cb326..4e5815e 100644 --- a/go.sum +++ b/go.sum @@ -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/xiaoqidun/jbig2 v1.1.1 h1:SjliS9ZO0e9HX5oYUfBuXrfFsXhWricrSz+XY4zORDI= 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-20260926132451-00d165f66ad7/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +github.com/xiaoqidun/pdfgo v0.0.0-20260926152052-3a4cb2e9266a h1:BT4VQisXVY8OlUdZFE6WTOIktGk8fCQOfkWlSxvL+cc= +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/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index ae8c5e1..6d8f874 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -398,10 +398,10 @@ func convertPDFDocument(args []js.Value) (any, error) { } // convertFile 使用库转换独立文件,不读取或修改当前阅读和编辑会话 -// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查及字体 +// 入参: args 随机读取、文件大小、转换选项、写出、进度、文件边界、取消检查、字体及按需字体读取 // 返回: any 转换报告, error 转换错误 func convertFile(args []js.Value) (any, error) { - if len(args) != 8 { + if len(args) != 9 { return nil, fmt.Errorf("missing conversion arguments") } settings := args[2] @@ -422,9 +422,13 @@ func convertFile(args []js.Value) (any, error) { return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) }, } - if len(fonts) > 0 { - options.RendererOptions = append(options.RendererOptions, ofdgo.WithFontFS(ofdgo.NewFontFS(fonts))) + fontSources := []fs.FS{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() { credentials, err := credentialsFromJS(value) if err != nil { @@ -476,6 +480,30 @@ func convertFile(args []js.Value) (any, error) { 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 将已经成功转换的文件逐个打包,不重复转换或读取整个文件 // 入参: args 文件列表、分块读取、分块写出、进度及取消检查 // 返回: any 输出格式, error 打包错误 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 50d0b4f..5cbdc2f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -783,15 +783,17 @@ func (e *Editor) editorFont(id string) (FontMetrics, error) { return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("fonts: %w", ErrBackendUnavailable)} } var data []byte - createdExternal := false + var external *Font for _, resource := range e.resources { if resource.font != nil && resource.font.ID == id { data = resource.data - createdExternal = resource.font.FontFile == "" + if resource.font.FontFile == "" { + external = resource.font + } break } } - if data == nil && e.source != nil && !createdExternal { + if data == nil && e.source != nil && external == nil { if e.fontRenderer == nil { e.fontRenderer = e.newRenderer(e.source.reader) } @@ -801,12 +803,9 @@ func (e *Editor) editorFont(id string) (FontMetrics, error) { } data = resolved.Data } - if data == nil && createdExternal { - reader, err := e.Reader() - if err != nil { - return nil, &EditError{Code: EditFontUnavailable, Err: err} - } - resolved, err := e.newRenderer(reader).ResolveFont(id, false) + if data == nil && external != nil { + reader := &Reader{doc: &Document{}, fontResourcesRead: true, fontCache: map[string]*Font{id: external}} + resolved, err := e.newRenderer(reader).ResolveFont(id, true) if err != nil { return nil, &EditError{Code: EditFontUnavailable, Err: err} } diff --git a/ofdgo_font_fs.go b/ofdgo_font_fs.go index 0480c39..ca82251 100644 --- a/ofdgo_font_fs.go +++ b/ofdgo_font_fs.go @@ -21,6 +21,7 @@ import ( "path" "sort" "strings" + "sync" "time" ) @@ -30,9 +31,10 @@ type FontFile struct { Data []byte } -// FontFS 内存字体文件系统 +// FontFS 缓存字体数据及名称索引的只读文件系统 type FontFS struct { files map[string][]byte + loaders map[string]func() ([]byte, error) names []string candidates []fontFileCandidate } @@ -62,6 +64,32 @@ func NewFontFS(fonts []FontFile) *FontFS { 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 获取字体文件数量 // 返回: int 字体文件数量 func (fsys *FontFS) Len() int { @@ -85,6 +113,13 @@ func (fsys *FontFS) Open(name string) (fs.File, error) { if !ok { 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{ Reader: bytes.NewReader(data), info: fontFileInfo{ @@ -281,7 +316,9 @@ type fontFileCandidate struct { face int base string lowerBase string + filename string normalized string + names []string fold string } @@ -293,11 +330,13 @@ func fontFileCandidates(names []string, base func(string) string) []fontFileCand for _, name := range names { if isFontFileName(name) { baseName := base(name) + normalized := fontNormalizeName(strings.TrimSuffix(baseName, path.Ext(baseName))) files = append(files, fontFileCandidate{ name: name, base: baseName, lowerBase: strings.ToLower(baseName), - normalized: fontNormalizeName(baseName), + filename: normalized, + normalized: normalized, fold: strings.ToLower(name), }) } @@ -370,7 +409,7 @@ func (m fontPatternMatcher) rankCandidate(file fontFileCandidate) int { } return fontMatchNone } - name := file.normalized + name := m.candidateName(file) if name == m.stem { return fontMatchExact } @@ -401,6 +440,26 @@ func (m fontPatternMatcher) rankCandidate(file fontFileCandidate) int { 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 获取规范字体名称的样式后缀 // 入参: name 规范字体名称 // 返回: string 样式后缀 @@ -441,7 +500,7 @@ func appendFontFileMatch(matches *[]fontFileMatch, seen map[fontFileKey]int, mat if rank == fontMatchNone { return } - suffix := matcher.styleSuffixNormalized(file.normalized) + suffix := matcher.styleSuffixNormalized(matcher.candidateName(file)) if rank == fontMatchFuzzy { suffix = matcher.styleSuffixNormalized(fontNormalizeName(file.base)) } diff --git a/ofdgo_font_name.go b/ofdgo_font_name.go index 8145c79..30b88bf 100644 --- a/ofdgo_font_name.go +++ b/ofdgo_font_name.go @@ -33,7 +33,7 @@ type fontNameRecord struct { Offset uint16 } -// appendFontFileNames 使用字体内部名称匹配,缺失时保留文件名 +// appendFontFileNames 建立各字体的内部名称与文件别名匹配候选 // 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称 // 返回: []fontFileCandidate 字体候选列表 func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate { @@ -41,8 +41,12 @@ func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]st replaced := false for face, names := range faces { file.face = face - for _, name := range names { - file.normalized = fontNormalizeName(name) + file.names = make([]string, len(names)) + for i, name := range names { + file.names[i] = fontNormalizeName(name) + } + for _, name := range file.names { + file.normalized = name if !replaced { candidates[index] = file replaced = true diff --git a/ofdgo_font_source.go b/ofdgo_font_source.go index 8d4798f..f353924 100644 --- a/ofdgo_font_source.go +++ b/ofdgo_font_source.go @@ -210,12 +210,12 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { seen := make(map[fontSourceKey]bool) for _, dir := range r.fontDirs { 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 _, 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) @@ -223,7 +223,7 @@ func (r *Renderer) fontSources(fontID string, font *Font) []fontSource { if canLoadSystemFonts() { for _, dir := range systemFontDirs() { 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...) { diff --git a/ofdgo_paint.go b/ofdgo_paint.go index 583b92b..fed170c 100644 --- a/ofdgo_paint.go +++ b/ofdgo_paint.go @@ -16,6 +16,7 @@ package ofdgo import ( "image/color" + "math" "strconv" "strings" ) @@ -31,6 +32,29 @@ func (r *Renderer) ResolveColor(value string, index *int, space string, alpha *i // 入参: value 颜色值, index 调色板索引, space 颜色空间标识, alpha 透明度 // 返回: 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 { 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 } parts := strings.Fields(value) - var components [4]uint8 + var components [4]float64 limit := 1<= count { for i, part := range parts[:count] { v, err := parseColorComponent(part) if err != nil || v < 0 || limit < v { - components = [4]uint8{} + components = [4]float64{} break } - components[i] = uint8((v*255 + limit/2) / limit) + components[i] = float64(v) / float64(limit) } } - 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)} + return kind, components } // parseColorComponent 解析颜色分量 @@ -232,6 +245,55 @@ func (r *Renderer) GradientStops(segments []ShdSegment, alpha *int) []ColorStop 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 按显式锚点补全渐变分段位置,保留原顺序 // 入参: segments 渐变分段 // 返回: []float64 分段位置 diff --git a/ofdgo_paint_model.go b/ofdgo_paint_model.go index 7e8d192..8da0ff0 100644 --- a/ofdgo_paint_model.go +++ b/ofdgo_paint_model.go @@ -68,7 +68,7 @@ func (r *Renderer) ResolvePaint(fill *FillColor) Paint { paint := Paint{Kind: PaintSolid, Color: colorToRGBA(base)} if node := fill.AxialShd; node != nil { 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])) { 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} @@ -77,7 +77,7 @@ func (r *Renderer) ResolvePaint(fill *FillColor) Paint { } if node := fill.RadialShd; node != nil && node.EndRadius > 0 { 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 { 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} diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 3b2e2df..b1ea53c 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -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.compositeCache = nil importer.clipTexts = make(map[*pdfgo.TextClip]TextObject) - visitor := pdfgo.Visitor{Path: importer.path, Text: importer.text, Image: importer.image, Warning: importer.warning} - 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 { + if err := importer.compositePage(nil, func(visitor pdfgo.Visitor) error { return reader.WalkPage(ctx, page, visitor) }); err != nil { return fmt.Errorf("import PDF page %d: %w", index+1, err) } if err := importer.flushPath(); err != nil { @@ -466,9 +459,6 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if !ok { 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 m := p.matrix.Mul(mark.StrokeMatrix) 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 对象边界 // 返回: *FillColor OFD颜色或渐变, 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) if paint.Tiling != nil { 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 { gradient := paint.Radial - start, end := p.matrix.Apply(gradient.Start), p.matrix.Apply(gradient.End) - scale := math.Hypot(p.matrix[0], p.matrix[1]) + matrix := p.matrix + 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 if gradient.Extend[0] { extend |= 1 @@ -551,9 +556,9 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) if gradient.Extend[1] { 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 - shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) + shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space) if err != nil { 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)} var err error - shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) + shading.Segment, err = p.gradientSegments(gradient.Stops, gradient.Space) if err != nil { return nil, err } diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index 40fa3aa..08a4b16 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -37,6 +37,7 @@ type pdfCompositePixel struct { values [4]float64 alpha float64 effect float64 + shape float64 } // pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元 @@ -47,6 +48,7 @@ type pdfCompositor struct { inverse pdfgo.Matrix cache *pdfCompositeCache masks map[*pdfgo.SoftMask][]float64 + meshes map[pdfMeshKey][]pdfMeshPixel } // pdfCompositeKey 区分图元的填充与描边几何 @@ -68,13 +70,14 @@ type pdfCompositeCache struct { geometry map[pdfCompositeKey]pdfCompositeGeometry images map[*pdfgo.Image]*pdfgo.ImageComponents masks map[*pdfgo.SoftMask][]pdfCompositeNode + meshes map[*pdfgo.MeshGradient][]pdfMeshTriangle } // compositingCache 按需创建单页合成缓存,页面切换时由导入器释放 // 返回: *pdfCompositeCache 当前页面缓存 func (p *pdfImporter) compositingCache() *pdfCompositeCache { 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 } @@ -162,8 +165,17 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis if err != nil { 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 { - if node.opaque(space) { + if node.opaque(space) || space.SRGBEquivalent() && node.direct() { if err := node.emit(p.visitor()); err != nil { 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 { if n.group != nil { 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 } for _, child := range n.children { @@ -214,6 +226,24 @@ func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) bool { 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 获取图元实际使用的图形状态 // 返回: pdfgo.Style 图形状态, bool 是否填充, bool 是否描边 func (n pdfCompositeNode) style() (pdfgo.Style, bool, bool) { diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index 3ef1f97..76c9f91 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -60,6 +60,7 @@ func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePix result = append([]pdfCompositePixel(nil), pixels...) for i := range result { 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() 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 - 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 uniformReady := false for y := 0; y < c.height; y++ { @@ -117,7 +125,10 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf opacity *= mask[index] } var values [4]float64 - if uniform { + if mesh != nil { + values = mesh[index].values + shape *= mesh[index].shape + } else if uniform { if !uniformReady { uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent) uniformReady = true @@ -140,10 +151,10 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if err != nil { return err } + if style.AlphaIsShape { + shape, opacity = shape*opacity, 1 + } if initial != nil { - if style.AlphaIsShape { - shape, opacity = shape*opacity, 1 - } pixel := initial[index] if err := pdfCompositePaintOver(&pixel, values, opacity, space, mode, compatible); err != nil { 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 { return err } + pixels[index].shape += shape * (1 - pixels[index].shape) } } return nil @@ -190,21 +202,71 @@ func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePi } } result := append([]pdfCompositePixel(nil), initial...) - if err := c.draw(node.children, result, space); err != nil { - return err + if g.Knockout { + 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) if err != nil { 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) } +// 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 移除组初始背景贡献后,将组结果合成到父空间 // 入参: pixels 父组输出, initial 初始背景, result 组结果, space 组空间, parent 父空间, opacity 组不透明度, mask 蒙版, mode 混合模式 // 返回: error 颜色或混合错误 func pdfCompositeGroup(pixels, initial, result []pdfCompositePixel, space, parent *pdfgo.ColorSpace, opacity float64, mask []float64, mode pdfgo.Name) error { for i, pixel := range result { + pixels[i].shape += pixel.shape * (1 - pixels[i].shape) if pixel.effect == 0 { continue } diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 546e390..6056623 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -39,6 +39,9 @@ type pdfPaintRegion struct { // 入参: mark 组边界状态, walk 组内容访问函数, 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" { 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 { 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) } if color.Alpha == 0 { diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go new file mode 100644 index 0000000..fcc5a96 --- /dev/null +++ b/ofdgo_pdf_mesh.go @@ -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 +} diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index 13d796a..d419ec4 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -16,72 +16,61 @@ package ofdgo import ( "math" - "slices" "github.com/xiaoqidun/pdfgo" ) -// pdfGradientSegments 将线性源颜色及设备四色的截断点表达为OFD分段 +// gradientSegments 保留线性源颜色的设备分量,校准颜色经源空间转换 // 入参: stops 源颜色分段, space 源颜色空间 -// 返回: []ShdSegment 等价RGB分段, error 不可线性表达的变换 -func pdfGradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) ([]ShdSegment, error) { - if len(stops) == 0 { - return nil, &pdfgo.UnsupportedError{Feature: "nonlinear gradient conversion"} +// 返回: []ShdSegment OFD颜色分段, error 不可线性表达的变换 +func (p *pdfImporter) gradientSegments(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) ([]ShdSegment, error) { + if err := pdfGradientStopsError(stops, space); err != nil { + return nil, err } - calibrated := space != nil && space.Calibrated() && !space.SRGBEquivalent() cmyk := space != nil && space.Model == "DeviceCMYK" && !space.Calibrated() - segments := make([]ShdSegment, 0, len(stops)) - appendStop := func(position float64, rgb [3]float64) { - 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))}}) - } - for i, b := range stops { - 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) - } - } - } + segments := make([]ShdSegment, len(stops)) + for i, stop := range stops { + paint := pdfgo.Paint{RGB: stop.RGB, Alpha: 1} + if cmyk { + paint.CMYK = &stop.Values } - 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 } +// 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线性分段 // 入参: paint 画刷 // 返回: error 不可等价表达的渐变 func pdfGradientError(paint pdfgo.Paint) error { + if paint.Mesh != nil { + return &pdfgo.UnsupportedError{Feature: "bicubic mesh conversion"} + } if paint.Axial != nil { - _, err := pdfGradientSegments(paint.Axial.Stops, paint.Axial.Space) - return err + return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space) } if paint.Radial != nil { - _, err := pdfGradientSegments(paint.Radial.Stops, paint.Radial.Space) - return err + return pdfGradientStopsError(paint.Radial.Stops, paint.Radial.Space) } return nil } @@ -90,7 +79,30 @@ func pdfGradientError(paint pdfgo.Paint) error { // 入参: mark 路径绘制信息 // 返回: 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 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色 @@ -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]) } 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 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