From f234d4ad10dedcc650c26145fad29d4219d4d38e Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 26 Sep 2026 21:36:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=BD=AC=E6=8D=A2):=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BAPDF=E8=BD=AC=E6=8D=A2=E4=B8=8E=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.css | 100 +++++-- assets/webui/ofdgo.js | 3 +- go.mod | 2 +- go.sum | 4 +- internal/webui/wasm.go | 13 +- ofdgo_convert.go | 4 + ofdgo_editor.go | 13 +- ofdgo_editor_history.go | 4 +- ofdgo_font_backend.go | 7 + ofdgo_font_cff.go | 69 ++++- ofdgo_font_glyf.go | 46 +++- ofdgo_font_hint.go | 14 +- ofdgo_pdf.go | 96 +++++-- ofdgo_pdf_composite.go | 394 ++++++++++++++++++++++++++++ ofdgo_pdf_composite_draw.go | 480 ++++++++++++++++++++++++++++++++++ ofdgo_pdf_group.go | 338 ++++++++++++++++++++++-- ofdgo_pdf_mask.go | 64 +++++ ofdgo_pdf_object.go | 79 ++++-- ofdgo_pdf_shading.go | 129 +++++++++ ofdgo_renderer_font_canvas.go | 7 + 20 files changed, 1761 insertions(+), 105 deletions(-) create mode 100644 ofdgo_pdf_composite.go create mode 100644 ofdgo_pdf_composite_draw.go create mode 100644 ofdgo_pdf_mask.go create mode 100644 ofdgo_pdf_shading.go diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index ec0d81a..fde36de 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -910,43 +910,103 @@ button:not(:disabled):hover { } @media (max-width: 700px) { - .convert-dialog .form-actions { flex-wrap: wrap; } - .convert-options { order: -1; flex-basis: 100%; justify-content: center; margin: 0 0 4px; } - #convertClose { margin-left: auto; } + .convert-dialog { --control-height: 36px; } + .convert-dialog > form { + display: flex; + flex-direction: column; + max-height: calc(100dvh - 26px); + } + .convert-dialog .form-title, + .convert-progress, + .convert-dialog .form-actions { flex-shrink: 0; } + .convert-table { + min-height: min(120px, 18dvh); + max-height: 50dvh; + } + .convert-progress:has(#convertStatus:empty) { display: none; } + .convert-dialog .form-actions { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto auto; + row-gap: 12px; + } + .convert-dialog .form-actions > .button { grid-row: 2; } + .convert-options { + grid-column: 1 / -1; + grid-row: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + width: 100%; + max-width: 360px; + margin: 0 auto; + } + .convert-options:has(#convertDPIRow[hidden]) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-width: 240px; + } + .convert-options .form-row { + grid-template-columns: minmax(0, 1fr); + gap: 6px; + text-align: center; + } + .convert-options label { width: auto; } + #convertAdd { grid-column: 1; } + #convertClear { grid-column: 2; } + #convertClose { grid-column: 4; } + #convertCancel, + #convertStart { grid-column: 5; } } @media (max-width: 620px) { - .convert-options { - grid-template-columns: calc(2em + 8px + var(--convert-control-width)) 132px; - } .convert-table table, .convert-table tbody { display: block; } .convert-table thead { display: none; } .convert-table tr { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: center; - gap: 8px; - padding: 8px; + gap: 10px 12px; + padding: 12px; border-bottom: 1px solid var(--line); } .convert-table tr:last-child { border-bottom: 0; } .convert-table tr:has(+ .convert-details) { padding-bottom: 0; border-bottom: 0; } .convert-table td { min-width: 0; padding: 0; border: 0; } - .convert-table td:first-child { flex: 0 0 calc(100% - var(--convert-control-width) - var(--control-height) - 16px); } - .convert-table td:nth-child(4) { order: 1; flex: 0 0 var(--convert-control-width); } - .convert-table td:last-child { order: 2; flex: 0 0 var(--control-height); } + .convert-table td:first-child { + grid-column: 1 / -1; + grid-row: 1; + padding-right: calc(var(--control-height) + 12px); + } + .convert-name { + overflow-wrap: anywhere; + white-space: normal; + line-height: 1.5; + } + .convert-table td:last-child { + grid-column: 3; + grid-row: 1; + align-self: start; + justify-self: end; + } .convert-table td[data-label] { - order: 3; - flex: 1 1 0; display: grid; - grid-template-columns: 2em var(--convert-control-width); + grid-row: 2; + grid-template-rows: auto var(--control-height); + justify-items: center; align-items: center; - justify-content: center; - gap: 8px; + gap: 6px; + } + .convert-table td[data-label]::before { + content: attr(data-label); + color: var(--text-muted); + font-size: 12px; + } + .convert-table input, + .convert-format, + .convert-format select { width: 100%; } + .convert-table .convert-details > td { + grid-column: 1 / -1; + justify-self: stretch; + padding: 0; } - .convert-table td[data-label]::before { content: attr(data-label); color: var(--text-muted); } - .convert-table .convert-details > td { flex: 0 0 100%; } } #sourceTextPanel { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 430e6c9..046c641 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -3376,7 +3376,7 @@ async function openOFD(file) { if (openSeq !== state.openSeq) return; let converted; try { - converted = await callWASM("ofdgoConvertPDF", bytes); + converted = await callWASM("ofdgoConvertPDF", bytes, await fontManager.files(fontManager.records())); } finally { if (openSeq === state.openSeq) { state.exportRequestID = 0; @@ -4474,6 +4474,7 @@ function renderBatchList() { item.cell = row.insertCell(); item.cell.textContent = item.text; item.cell.dataset.status = item.status; + item.cell.dataset.label = "状态"; const action = row.insertCell(); const remove = document.createElement("button"); remove.type = "button"; diff --git a/go.mod b/go.mod index 148b8ab..bb0629f 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-20260926072349-1d4e7a9c2616 + github.com/xiaoqidun/pdfgo v0.0.0-20260926132451-00d165f66ad7 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 5cbfe60..c0cb326 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-20260926072349-1d4e7a9c2616 h1:16JKsw9CKBf+BBVeiiHg12bg7h1ocKSxyhUpw6eRIBk= -github.com/xiaoqidun/pdfgo v0.0.0-20260926072349-1d4e7a9c2616/go.mod h1:4Pf0JO0BEf1uzRY+uZjlMjN/OPn3PYa0YMi6cqPPTLQ= +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/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 c87caac..ae8c5e1 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -367,18 +367,25 @@ func safeCall(fn func([]js.Value) (any, error), args []js.Value) (data any, err } // convertPDFDocument 通过库转换PDF,不替换当前文档会话 -// 入参: args PDF数据 +// 入参: args PDF数据、外部字体及进度回调 // 返回: any OFD数据及转换警告, error 错误信息 func convertPDFDocument(args []js.Value) (any, error) { - if len(args) != 2 { + if len(args) != 3 { return nil, fmt.Errorf("missing PDF data") } data, err := bytesFromJS(args[0]) if err != nil { return nil, err } + fonts, err := fontsFromJS(args[1]) + if err != nil { + return nil, err + } var output bytes.Buffer - options := ofdgo.PDFImportOptions{OnProgress: editorOperationProgress(args[1])} + options := ofdgo.PDFImportOptions{OnProgress: editorOperationProgress(args[2])} + if len(fonts) > 0 { + options.RendererOptions = []ofdgo.RendererOption{ofdgo.WithFontFS(ofdgo.NewFontFS(fonts))} + } report, err := ofdgo.ConvertPDF(context.Background(), bytes.NewReader(data), int64(len(data)), &output, options) if err != nil { return nil, err diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 78809cd..6001d30 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -44,6 +44,7 @@ type ConvertProgress struct { // PageRange使用一基页码表达式,与Pages互斥,空字符串表示全部页面 // SkipUnchanged在源格式与目标相同且未选页时不写出数据,不校验或重新生成源文档 // PDF先转换为OFD对象再输出,PDF选项中的进度回调仍会调用 +// RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 @@ -180,9 +181,12 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ switch report.Input { case "pdf": pdfOptions := options.PDF + pdfOptions.RendererOptions = append(append([]RendererOption(nil), pdfOptions.RendererOptions...), options.RendererOptions...) if options.Backends != nil { pdfOptions.Backends = options.Backends } + options.RendererOptions = pdfOptions.RendererOptions + options.Backends = pdfOptions.Backends pdfOptions.OnProgress = func(stage string, completed, total int) error { if err := progress(stage, completed, total); err != nil { return err diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 8349443..42f462c 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -46,6 +46,7 @@ type Editor struct { pages []PageContent resources []editorResource fonts map[string]*FontResource + fontDirs []string fontFS []fs.FS fontRenderer *Renderer fontMetrics map[string]FontMetrics @@ -65,8 +66,16 @@ type Editor struct { encryption *encryptionState } +// SetFontDirs 设置编辑与几何度量使用的外部字体目录,不嵌入或替换文档字体 +// 入参: dirs 字体目录,空参数清除目录配置 +func (e *Editor) SetFontDirs(dirs ...string) { + e.fontDirs = slices.Clone(dirs) + e.fontRenderer = nil + e.fontMetrics = make(map[string]FontMetrics) +} + // SetFontFS 设置编辑与几何度量使用的外部字体来源,不嵌入或替换文档字体 -// 入参: fsys 字体文件系统,空参数恢复默认来源 +// 入参: fsys 字体文件系统,空参数清除文件系统配置 func (e *Editor) SetFontFS(fsys ...fs.FS) { e.fontFS = slices.Clone(fsys) e.fontRenderer = nil @@ -112,7 +121,7 @@ func (e *Editor) Backends() RenderBackends { // 入参: reader 当前文档快照 // 返回: *Renderer 度量器 func (e *Editor) newRenderer(reader *Reader) *Renderer { - return NewRenderer(reader, WithFontFS(e.fontFS...), WithRenderBackends(e.backends)) + return NewRenderer(reader, WithFontDirs(e.fontDirs...), WithFontFS(e.fontFS...), WithRenderBackends(e.backends)) } // editorResource 文档内嵌资源 diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index eb72f32..66be880 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -35,7 +35,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error { } changed := e.revision != before.revision e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit - e.backends, e.fontFS = before.backends, before.fontFS + e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics if changed { e.serial, e.revision = before.serial, before.revision @@ -83,7 +83,7 @@ func (e *Editor) restoreTransaction(state Editor) { maps.Copy(state.resourceID, e.resourceID) state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID) - state.backends, state.fontFS = e.backends, e.fontFS + state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS state.fontRenderer, state.fontMetrics = e.fontRenderer, e.fontMetrics *e = state } diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index 7259850..8e9fd94 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -42,6 +42,13 @@ type FontOutlineBatch interface { GlyphOutlines(glyphs []uint16, size float64) ([]GeometryPath, error) } +// FontGlyphDiagnostics 提供已提取字形的恢复提示,不将提示作为轮廓解析失败 +// 字形不存在或不支持的指令仍由GlyphOutline返回错误 +type FontGlyphDiagnostics interface { + FontMetrics + GlyphWarning(glyph uint16) error +} + // FontShaper 提供可选的横向文字塑形,仅用于显式新建或重排 // 字形按逻辑簇顺序排列,Cluster使用原文的符文索引,坐标为毫米且纵轴向下 type FontShaper interface { diff --git a/ofdgo_font_cff.go b/ofdgo_font_cff.go index bb81b2e..709b1ef 100644 --- a/ofdgo_font_cff.go +++ b/ofdgo_font_cff.go @@ -39,7 +39,7 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) { if err == nil { cffData = sanitized } - cffData, err = normalizeCFFDotsection(cffData) + cffData, err = normalizeCFFCharstrings(cffData) if err != nil { return nil, nil, err } @@ -83,10 +83,10 @@ func wrapCFFToOTF(cffData []byte) ([]byte, map[rune]uint16, error) { // cffDict 使用float64存储所有数值,以统一处理整数和实数 type cffDict map[int][]float64 -// normalizeCFFDotsection 移除旧版Type2字形程序中无效果的dotsection指令 +// normalizeCFFCharstrings 规范化Type2提示指令的等价编码,保留轮廓和提示数据 // 入参: data CFF字体数据 // 返回: []byte 标准化后的CFF数据, error 错误信息 -func normalizeCFFDotsection(data []byte) ([]byte, error) { +func normalizeCFFCharstrings(data []byte) ([]byte, error) { if len(data) < 4 || int(data[2]) >= len(data) { return nil, fmt.Errorf("invalid CFF header") } @@ -107,18 +107,23 @@ func normalizeCFFDotsection(data []byte) ([]byte, error) { if charStart < topStart+topSize || charSize == 0 || charStart+charSize > len(data) { return nil, fmt.Errorf("invalid CFF charstrings") } - if !bytes.Contains(data[charStart:charStart+charSize], []byte{12, 0}) { - return data, nil - } chars := readCFFIndexItems(data, charStart) changed := false for index, charstring := range chars { - normalized, removed, err := stripType2Dotsection(charstring) + normalized, inserted, err := explicitType2CounterStems(charstring) if err != nil { return nil, err } + changed = changed || inserted + if bytes.Contains(normalized, []byte{12, 0}) { + var removed bool + normalized, removed, err = stripType2Dotsection(normalized) + if err != nil { + return nil, err + } + changed = changed || removed + } chars[index] = normalized - changed = changed || removed } if !changed { return data, nil @@ -165,6 +170,54 @@ func normalizeCFFDotsection(data []byte) ([]byte, error) { return result, nil } +// explicitType2CounterStems 将计数掩码前省略的vstemhm补为显式指令,不改变笔画提示及掩码 +// 入参: data Type2字形程序 +// 返回: []byte 等价程序, bool 是否发生变更, error 字节码错误 +func explicitType2CounterStems(data []byte) ([]byte, bool, error) { + operands, hints := 0, 0 + for index := 0; index < len(data); { + start, op := index, data[index] + index++ + switch { + case op == 28: + index += 2 + operands++ + case op == 255: + index += 4 + operands++ + case op >= 247: + index++ + operands++ + case op >= 32: + operands++ + case op == 1 || op == 3 || op == 18 || op == 23: + hints += operands / 2 + operands = 0 + case op == 19 || op == 20: + hints += operands / 2 + index += (hints + 7) / 8 + if index > len(data) { + return nil, false, fmt.Errorf("incomplete Type2 hint mask") + } + if op == 20 && operands >= 2 { + result := make([]byte, 0, len(data)+1) + result = append(result, data[:start]...) + result = append(result, 23) + return append(result, data[start:]...), true, nil + } + operands = 0 + case op == 12 && index < len(data) && data[index] == 0: + index++ + default: + return data, false, nil + } + if index > len(data) { + return nil, false, fmt.Errorf("incomplete Type2 operand") + } + } + return data, false, nil +} + // stripType2Dotsection 保留字形指令与掩码并移除无效果的旧指令 // 入参: data Type2字形程序 // 返回: []byte 标准化后的程序, bool 是否发生变更, error 错误信息 diff --git a/ofdgo_font_glyf.go b/ofdgo_font_glyf.go index 5f841b1..40bae33 100644 --- a/ofdgo_font_glyf.go +++ b/ofdgo_font_glyf.go @@ -16,6 +16,7 @@ package ofdgo import ( "encoding/binary" + "errors" "fmt" "math" "sync" @@ -25,10 +26,12 @@ import ( // sfntOutliner 缓存字体程序并提取独立字形轮廓 type sfntOutliner struct { - font *font.SFNT - once sync.Once - program *ttInterpreter - err error + font *font.SFNT + once sync.Once + program *ttInterpreter + err error + mu sync.Mutex + warnings map[uint16]error } // ttGlyph 保存字形轮廓及四个度量虚拟点 @@ -36,6 +39,7 @@ type ttGlyph struct { points []ttPoint ends []uint16 metrics *[4]ttPoint + warning error } // path 按TrueType指令和隐含点规则生成闭合轮廓,CFF保留原有解析 @@ -128,6 +132,9 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) if err != nil { return result, err } + if result.warning != nil { + f.recordGlyphWarning(id, result.warning) + } left := int32(int16(binary.BigEndian.Uint16(data[2:]))) - int32(f.font.Hmtx.LeftSideBearing(id)) top := int32(int16(binary.BigEndian.Uint16(data[8:]))) if result.metrics != nil { @@ -153,13 +160,39 @@ func (f *sfntOutliner) glyph(id uint16, active map[uint16]bool) (ttGlyph, error) return result, nil } vm := f.program.clone() - vm.zones[1], vm.ends = result.points, result.ends + vm.zones[1], vm.ends = append([]ttPoint(nil), result.points...), result.ends if err := vm.run(program); err != nil { + if errors.Is(err, errTTStackUnderflow) { + result.warning = err + f.recordGlyphWarning(id, err) + return result, nil + } return result, fmt.Errorf("TrueType glyph %d: %w", id, err) } + result.points = vm.zones[1] return result, nil } +// recordGlyphWarning 记录字形恢复提示,允许并发提取轮廓 +// 入参: id 字形编号, warning 恢复原因 +func (f *sfntOutliner) recordGlyphWarning(id uint16, warning error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.warnings == nil { + f.warnings = make(map[uint16]error) + } + f.warnings[id] = warning +} + +// glyphWarning 返回字形恢复为执行前轮廓的原因 +// 入参: id 字形编号 +// 返回: error 恢复原因,无恢复时为空 +func (f *sfntOutliner) glyphWarning(id uint16) error { + f.mu.Lock() + defer f.mu.Unlock() + return f.warnings[id] +} + // readTTSimple 读取简单字形的重复标志、差分坐标和字节码 // 入参: data 字形数据, count 轮廓数 // 返回: ttGlyph 字形轮廓, []byte 字节码, error 数据错误 @@ -301,6 +334,9 @@ func (f *sfntOutliner) composite(data []byte, active map[uint16]bool) (ttGlyph, if err != nil { return result, nil, err } + if part.warning != nil { + result.warning = fmt.Errorf("TrueType component %d: %w", id, part.warning) + } if flags&0x200 != 0 { result.metrics = new([4]ttPoint) copy(result.metrics[:], part.points[len(part.points)-4:]) diff --git a/ofdgo_font_hint.go b/ofdgo_font_hint.go index 346284c..9cf9d65 100644 --- a/ofdgo_font_hint.go +++ b/ofdgo_font_hint.go @@ -16,11 +16,15 @@ package ofdgo import ( "encoding/binary" + "errors" "fmt" "maps" "math" ) +// errTTStackUnderflow 表示字形程序缺少必需的栈参数 +var errTTStackUnderflow = errors.New("TrueType instruction stack underflow") + // ttPoint 保存TrueType当前坐标、原始坐标及点状态,坐标使用26.6定点数 type ttPoint struct { x, y, ox, oy int32 @@ -112,7 +116,7 @@ func (v *ttInterpreter) clone() *ttInterpreter { // 返回: int32 操作数 func (v *ttInterpreter) pop() int32 { if len(v.stack) == 0 { - v.err = fmt.Errorf("TrueType instruction stack underflow") + v.err = errTTStackUnderflow return 0 } x := v.stack[len(v.stack)-1] @@ -319,7 +323,7 @@ func (v *ttInterpreter) execute(code []byte, depth int) error { g.freedom = axis } case op >= 6 && op <= 9 || op == 0x86 || op == 0x87: - p2, p1 := v.point(g.zone[2], v.pop()), v.point(g.zone[1], v.pop()) + p1, p2 := v.point(g.zone[2], v.pop()), v.point(g.zone[1], v.pop()) direction := func(x, y int32) [2]float64 { a, b := float64(x), float64(y) if op&1 != 0 { @@ -443,7 +447,7 @@ func (v *ttInterpreter) execute(code []byte, depth int) error { } for n := int32(0); n < count; n++ { if err = v.execute(body, depth+1); err != nil { - return err + return fmt.Errorf("TrueType function %d: %w", id, err) } } case op == 0x2c || op == 0x89: @@ -563,9 +567,9 @@ func (v *ttInterpreter) execute(code []byte, depth int) error { case op == 0x49 || op == 0x4a: p1, p2 := v.point(g.zone[1], v.pop()), v.point(g.zone[0], v.pop()) if op&1 == 0 { - v.push(ttProject(p1.ox-p2.ox, p1.oy-p2.oy, g.dual)) + v.push(ttProject(p2.ox-p1.ox, p2.oy-p1.oy, g.dual)) } else { - v.push(ttProject(p1.x-p2.x, p1.y-p2.y, g.projection)) + v.push(ttProject(p2.x-p1.x, p2.y-p1.y, g.projection)) } case op == 0x4b: v.push(v.units) diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 1c2b0d7..3b2e2df 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -29,13 +29,17 @@ import ( // PDFImportOptions 指定PDF转换的密码、运行时后端、进度通知与检查策略 // Password按PDFDocEncoding字节传入,空值尝试默认空密码 // Strict禁止恢复缺失资源或丢失内容语义,默认在报告中记录警告 +// RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置 +// RasterDPI指定局部透明效果合成精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成 // OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知 type PDFImportOptions struct { - Password []byte - Backends *RenderBackends - Progress func(int) error - OnProgress func(stage string, completed, total int) error - Strict bool + Password []byte + Backends *RenderBackends + RendererOptions []RendererOption + Progress func(int) error + OnProgress func(stage string, completed, total int) error + Strict bool + RasterDPI float64 } // PDFImportReport 汇总转换页数、对象数、链接数和转换警告 @@ -53,12 +57,14 @@ type pdfImporter struct { ctx context.Context reader *pdfgo.Reader editor *Editor + renderer *Renderer report PDFImportReport matrix pdfgo.Matrix page int fontIDs map[*pdfgo.Font]string fontMetrics map[string]FontMetrics fontRepairLimit map[*pdfgo.Font]uint16 + fontWarnings map[string]bool type1Glyphs map[*pdfgo.Font]map[string]uint16 clipTexts map[*pdfgo.TextClip]TextObject cmykSpace string @@ -72,6 +78,9 @@ type pdfImporter struct { pageHeight float64 pendingPath *pdfgo.PathMark warning func(pdfgo.Diagnostic) + rasterDPI float64 + rasterWarned bool + compositeCache *pdfCompositeCache } // ImportPDF 将PDF内容转换为独立OFD编辑文档,失败时不返回部分结果 @@ -81,6 +90,19 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI if err := ctx.Err(); err != nil { return nil, PDFImportReport{}, err } + if !finite(options.RasterDPI) || options.RasterDPI < 0 { + return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI") + } + renderer := NewRenderer(&Reader{}, options.RendererOptions...) + if options.Backends != nil { + WithRenderBackends(*options.Backends)(renderer) + } + if options.RasterDPI != 0 { + renderer.DPI = options.RasterDPI + } + if !finite(renderer.DPI) || renderer.DPI <= 0 { + return nil, PDFImportReport{}, fmt.Errorf("invalid PDF raster DPI") + } if options.OnProgress != nil { if err := options.OnProgress("open", 0, 0); err != nil { return nil, PDFImportReport{}, err @@ -91,10 +113,9 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI return nil, PDFImportReport{}, err } editor := NewEditor() - if options.Backends != nil { - editor.SetRenderBackends(*options.Backends) - } - importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, fontIDs: map[*pdfgo.Font]string{}, fontMetrics: map[string]FontMetrics{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} + editor.SetRenderBackends(renderer.Backends()) + editor.fontDirs, editor.fontFS = renderer.FontSources() + importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fontMetrics: map[string]FontMetrics{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} if security := reader.Encryption(); security != nil && !security.Owner && security.Permissions&0xf3c != 0xf3c { if options.Strict { return nil, PDFImportReport{}, &pdfgo.UnsupportedError{Feature: "PDF access permission conversion"} @@ -136,13 +157,18 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI matrix, width, height := pdfPageMatrix(page) importer.matrix = matrix importer.page = index + importer.rasterWarned = false importer.pageBox = page.CropBox importer.pageWidth = width importer.pageHeight = height 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 { @@ -339,7 +365,16 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { for _, mark := range path.Text { object, ok := p.clipTexts[mark] if !ok { - return nil, fmt.Errorf("missing PDF text clipping glyphs") + if err := p.flushPath(); err != nil { + return nil, err + } + count := len(p.objects) + if err := p.text(pdfgo.TextMark{Font: mark.Font, Glyphs: mark.Glyphs, Positions: mark.Positions, Matrix: mark.Matrix, Size: mark.Size, HorizontalScale: mark.HorizontalScale, Mode: 7, Clip: mark, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}); err != nil { + return nil, err + } + p.objects = p.objects[:count] + p.report.TextObjects-- + object = p.clipTexts[mark] } box, err := ParseBox(object.Boundary) if err != nil { @@ -357,8 +392,8 @@ func (p *pdfImporter) clips(paths []pdfgo.Path, origin Box) (*Clips, error) { // 入参: mark PDF路径绘制信息 // 返回: error 错误信息 func (p *pdfImporter) path(mark pdfgo.PathMark) error { - if mark.Style.BlendMode == "Multiply" { - return &pdfgo.UnsupportedError{Feature: "multiply path"} + if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "path blend mode " + string(mark.Style.BlendMode)} } if !mark.Stroke || mark.Fill || mark.Style.Stroke.Alpha != 1 { if err := p.flushPath(); err != nil { @@ -400,11 +435,19 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { var err error mark.Style, err = p.maskStyle(mark.Style) if err != nil { + if p.rasterMaskAllowed(err) { + mask := mark.Style.SoftMask + mark.Style.SoftMask = nil + return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Path(mark) }) + } return err } if mark.Fill && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || mark.Stroke && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "color separation overprint"} } + if p.warning != nil && (mark.Fill && pdfGradientError(mark.Style.Fill) != nil || mark.Stroke && pdfGradientError(mark.Style.Stroke) != nil) { + return p.gradientPath(mark) + } var points []pdfgo.Point for _, segment := range mark.Path.Segments { for _, point := range segment.Points { @@ -447,13 +490,18 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { } return drawing.paintColor(value, origin) } - fillColor, err := paint(mark.Style.Fill) - if err != nil { - return err + fillColor, strokePaint := p.color(mark.Style.Fill), p.color(mark.Style.Stroke) + if mark.Fill { + fillColor, err = paint(mark.Style.Fill) + if err != nil { + return err + } } - strokePaint, err := paint(mark.Style.Stroke) - if err != nil { - return err + if mark.Stroke { + strokePaint, err = paint(mark.Style.Stroke) + if err != nil { + return err + } } strokeColor := StrokeColor(*strokePaint) clips, err := p.clips(mark.Style.Clips, box) @@ -504,8 +552,10 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) 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)} - for _, stop := range gradient.Stops { - shading.Segment = append(shading.Segment, ShdSegment{Position: stop.Position, Color: ShdColor{Value: pdfNumbers(math.Round(stop.RGB[0]*255), math.Round(stop.RGB[1]*255), math.Round(stop.RGB[2]*255))}}) + var err error + shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) + if err != nil { + return nil, err } color.Value = "" color.RadialShd = shading @@ -536,8 +586,10 @@ func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) extend |= 2 } 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)} - for _, stop := range gradient.Stops { - shading.Segment = append(shading.Segment, ShdSegment{Position: stop.Position, Color: ShdColor{Value: pdfNumbers(math.Round(stop.RGB[0]*255), math.Round(stop.RGB[1]*255), math.Round(stop.RGB[2]*255))}}) + var err error + shading.Segment, err = pdfGradientSegments(gradient.Stops, gradient.Space) + if err != nil { + return nil, err } color.Value = "" color.AxialShd = shading diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go new file mode 100644 index 0000000..40fa3aa --- /dev/null +++ b/ofdgo_pdf_composite.go @@ -0,0 +1,394 @@ +// 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" + "image" + "image/color" + "math" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfCompositeNode 保留局部合成所需的原始图元和透明组层级 +type pdfCompositeNode struct { + path *pdfgo.PathMark + text *pdfgo.TextMark + image *pdfgo.ImageMark + group *pdfgo.GroupMark + children []pdfCompositeNode +} + +// pdfCompositePixel 保存非预乘源空间分量及累计透明度 +type pdfCompositePixel struct { + values [4]float64 + alpha float64 + effect float64 +} + +// pdfCompositor 在指定局部区域中按源颜色空间合成PDF图元 +type pdfCompositor struct { + importer *pdfImporter + box Box + width, height int + inverse pdfgo.Matrix + cache *pdfCompositeCache + masks map[*pdfgo.SoftMask][]float64 +} + +// pdfCompositeKey 区分图元的填充与描边几何 +type pdfCompositeKey struct { + path *pdfgo.PathMark + text *pdfgo.TextMark + image *pdfgo.ImageMark + stroke bool +} + +// pdfCompositeGeometry 保存后端中立的绘制命令及其页面边界 +type pdfCompositeGeometry struct { + scene *RasterPage + box Box +} + +// pdfCompositeCache 在单页内复用几何、图像分量及蒙版内容 +type pdfCompositeCache struct { + geometry map[pdfCompositeKey]pdfCompositeGeometry + images map[*pdfgo.Image]*pdfgo.ImageComponents + masks map[*pdfgo.SoftMask][]pdfCompositeNode +} + +// 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{}} + } + return p.compositeCache +} + +// coverage 复用已编译的覆盖场景,跳过与当前区域无交集的图元 +// 入参: node 图元, stroke 是否描边 +// 返回: image.Image 当前区域覆盖或空值, error 编译或渲染错误 +func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Image, error) { + if node.text != nil && len(node.text.Font.Program) == 0 && node.text.Font.BoundingBox != nil { + b := c.importer.compositeTextBounds(*node.text, stroke) + if b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y { + return nil, nil + } + } + key := pdfCompositeKey{path: node.path, text: node.text, image: node.image, stroke: stroke} + geometry, ok := c.cache.geometry[key] + if !ok { + var err error + geometry.scene, geometry.box, err = c.importer.compileGroup(func(local *pdfImporter) error { return node.coverage(local, stroke) }) + if err != nil { + return nil, err + } + c.cache.geometry[key] = geometry + } + b := geometry.box + if geometry.scene == nil || b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y { + return nil, nil + } + return c.importer.renderGroupScene(geometry.scene, c.box) +} + +// compositeTextBounds 按字体描述符计算保守边界,仅用于排除不相交的外部字体 +// 入参: mark 具有字体边界的文字, stroke 是否描边 +// 返回: Box 包含描边和抗锯齿边缘的页面范围 +func (p *pdfImporter) compositeTextBounds(mark pdfgo.TextMark, stroke bool) Box { + b := mark.Font.BoundingBox + matrix := p.matrix.Mul(mark.Matrix) + var points []pdfgo.Point + for _, origin := range mark.Positions { + for _, point := range []pdfgo.Point{{X: b.XMin, Y: b.YMin}, {X: b.XMax, Y: b.YMin}, {X: b.XMax, Y: b.YMax}, {X: b.XMin, Y: b.YMax}} { + points = append(points, matrix.Apply(pdfgo.Point{X: origin.X + point.X*mark.Size*mark.HorizontalScale/1000, Y: origin.Y + point.Y*mark.Size/1000})) + } + } + box := pdfBounds(points) + marginX, marginY := 25.4/p.rasterDPI, 25.4/p.rasterDPI + if stroke { + m := mark.StrokeMatrix + if m == (pdfgo.Matrix{}) { + m = pdfgo.Identity() + } + m = p.matrix.Mul(m) + distance := mark.Style.LineWidth / 2 * math.Max(1, mark.Style.MiterLimit) + marginX += distance * (math.Abs(m[0]) + math.Abs(m[2])) + marginY += distance * (math.Abs(m[1]) + math.Abs(m[3])) + } + return Box{X: box.X - marginX, Y: box.Y - marginY, W: box.W + 2*marginX, H: box.H + 2*marginY} +} + +// pdfCompositeNodes 收集原始绘制顺序,语义警告只在首次解析时交付 +// 入参: walk 内容访问函数, warning 警告回调 +// 返回: []pdfCompositeNode 原始图元, error 解析错误 +func pdfCompositeNodes(walk func(pdfgo.Visitor) error, warning func(pdfgo.Diagnostic)) ([]pdfCompositeNode, error) { + var nodes []pdfCompositeNode + v := pdfgo.Visitor{Warning: warning} + v.Path = func(mark pdfgo.PathMark) error { nodes = append(nodes, pdfCompositeNode{path: &mark}); return nil } + v.Text = func(mark pdfgo.TextMark) error { nodes = append(nodes, pdfCompositeNode{text: &mark}); return nil } + v.Image = func(mark pdfgo.ImageMark) error { nodes = append(nodes, pdfCompositeNode{image: &mark}); return nil } + v.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + children, err := pdfCompositeNodes(walk, warning) + if err != nil { + return err + } + nodes = append(nodes, pdfCompositeNode{group: &mark, children: children}) + return nil + } + err := walk(v) + return nodes, err +} + +// compositePage 保留独立不透明对象,仅将需要背景参与的效果在局部区域合成 +// 入参: space 页面混合空间, walk 页面内容 +// 返回: error 转换错误 +func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Visitor) error) error { + nodes, err := pdfCompositeNodes(walk, p.warning) + if err != nil { + return err + } + for i, node := range nodes { + if node.opaque(space) { + if err := node.emit(p.visitor()); err != nil { + return fmt.Errorf("convert graphic %d: %w", i+1, err) + } + } else { + if p.warning == nil { + return &pdfgo.UnsupportedError{Feature: "page color-space compositing"} + } + if err := p.flushPath(); err != nil { + return err + } + if err := p.compositeRegion(nodes[:i], node, space, true); err != nil { + return fmt.Errorf("composite graphic %d: %w", i+1, err) + } + } + } + return p.flushPath() +} + +// opaque 检查图元是否无需与页面背景混合 +// 入参: space 当前混合空间 +// 返回: bool 是否可直接保留 +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) { + return false + } + for _, child := range n.children { + if !child.opaque(space) { + return false + } + } + return true + } + style, fill, stroke := n.style() + if style.SoftMask != nil || !pdfNormalBlend(style.BlendMode) || fill && style.Fill.Alpha != 1 || stroke && style.Stroke.Alpha != 1 { + return false + } + if fill && style.FillOverprint && pdfOverprintNeedsSeparation(style.Fill) || stroke && style.StrokeOverprint && pdfOverprintNeedsSeparation(style.Stroke) { + return false + } + if fill && pdfGradientError(style.Fill) != nil || stroke && pdfGradientError(style.Stroke) != nil { + return false + } + if n.image != nil && (n.image.Image.Mask != nil || n.image.Image.SoftMask != nil) { + return false + } + return true +} + +// style 获取图元实际使用的图形状态 +// 返回: pdfgo.Style 图形状态, bool 是否填充, bool 是否描边 +func (n pdfCompositeNode) style() (pdfgo.Style, bool, bool) { + if n.path != nil { + return n.path.Style, n.path.Fill, n.path.Stroke + } + if n.text != nil { + mode := n.text.Mode % 4 + return n.text.Style, mode == 0 || mode == 2, mode == 1 || mode == 2 + } + return n.image.Style, true, false +} + +// emit 向现有转换器交付原始图元 +// 入参: visitor 图元访问器 +// 返回: error 转换错误 +func (n pdfCompositeNode) emit(visitor pdfgo.Visitor) error { + if n.path != nil { + return visitor.Path(*n.path) + } + if n.text != nil { + return visitor.Text(*n.text) + } + if n.image != nil { + return visitor.Image(*n.image) + } + return visitor.Group(*n.group, func(v pdfgo.Visitor) error { + for _, child := range n.children { + if err := child.emit(v); err != nil { + return err + } + } + return nil + }) +} + +// coverage 构建白色几何覆盖,颜色和透明度由合成器单独计算 +// 入参: p 局部导入器, stroke 是否只取描边 +// 返回: error 几何错误 +func (n pdfCompositeNode) coverage(p *pdfImporter, stroke bool) error { + if n.group != nil { + for _, child := range n.children { + if child.group != nil { + if err := child.coverage(p, false); err != nil { + return err + } + continue + } + _, fill, hasStroke := child.style() + if fill { + if err := child.coverage(p, false); err != nil { + return err + } + } + if hasStroke { + if err := child.coverage(p, true); err != nil { + return err + } + } + } + return nil + } + style, _, _ := n.style() + style.Fill, style.Stroke = pdfgo.Paint{RGB: [3]float64{1, 1, 1}, Alpha: 1}, pdfgo.Paint{RGB: [3]float64{1, 1, 1}, Alpha: 1} + style.SoftMask, style.BlendMode = nil, "Normal" + style.FillOverprint, style.StrokeOverprint = false, false + if n.path != nil { + mark := *n.path + mark.Style = style + mark.Fill, mark.Stroke = !stroke, stroke + return p.path(mark) + } + if n.text != nil { + mark := *n.text + mark.Style = style + mark.Mode = 0 + if stroke { + mark.Mode = 1 + } + return p.text(mark) + } + mark := n.image + path := pdfgo.Path{Segments: []pdfgo.Segment{ + {Operator: "M", Points: []pdfgo.Point{mark.Matrix.Apply(pdfgo.Point{})}}, + {Operator: "L", Points: []pdfgo.Point{mark.Matrix.Apply(pdfgo.Point{X: 1})}}, + {Operator: "L", Points: []pdfgo.Point{mark.Matrix.Apply(pdfgo.Point{X: 1, Y: 1})}}, + {Operator: "L", Points: []pdfgo.Point{mark.Matrix.Apply(pdfgo.Point{Y: 1})}}, + {Operator: "C"}, + }} + return p.path(pdfgo.PathMark{Path: path, Style: style, Fill: true}) +} + +// compositeRegion 按原始背景计算局部效果,不将组外文字和图形栅格化 +// 入参: backdrop 前序图元, node 当前效果, space 页面混合空间, flatten 是否合并页面背景 +// 返回: error 合成或保存错误 +func (p *pdfImporter) compositeRegion(backdrop []pdfCompositeNode, node pdfCompositeNode, space *pdfgo.ColorSpace, flatten bool) error { + scene, box, err := p.compileGroup(func(local *pdfImporter) error { + if node.group != nil { + return node.coverage(local, false) + } + _, fill, stroke := node.style() + if fill { + if err := node.coverage(local, false); err != nil { + return err + } + } + if stroke { + return node.coverage(local, true) + } + return nil + }) + if err != nil || scene == nil { + return err + } + inverse, ok := p.matrix.Inverse() + if !ok { + return fmt.Errorf("invalid PDF compositing transform") + } + w, h, err := (&RasterPage{Width: box.W, Height: box.H, DPI: p.rasterDPI}).PixelSize() + if err != nil { + return err + } + output := image.NewNRGBA(image.Rect(0, 0, w, h)) + cache := p.compositingCache() + step := 25.4 / p.rasterDPI + for y := 0; y < h; y += 256 { + for x := 0; x < w; x += 256 { + width, height := min(256, w-x), min(256, h-y) + c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, masks: map[*pdfgo.SoftMask][]float64{}} + pixels := make([]pdfCompositePixel, width*height) + if err := c.draw(backdrop, pixels, space); err != nil { + return err + } + for i := range pixels { + pixels[i].effect = 0 + } + if err := c.draw([]pdfCompositeNode{node}, pixels, space); err != nil { + return err + } + for i, pixel := range pixels { + if pixel.effect == 0 { + continue + } + rgb, err := pdfCompositeOutputColor(space, pixel.values) + if err != nil { + return err + } + alpha := pixel.alpha + if flatten { + for j := range rgb { + rgb[j] = pixel.alpha*rgb[j] + 1 - pixel.alpha + } + alpha = 1 + } + output.SetNRGBA(x+i%width, y+i/width, color.NRGBA{R: uint8(math.Round(rgb[0] * 255)), G: uint8(math.Round(rgb[1] * 255)), B: uint8(math.Round(rgb[2] * 255)), A: uint8(math.Round(alpha * 255))}) + } + } + } + return p.appendRasterGroup(output, box, 1) +} + +// pdfCompositeOutputColor 将合成结果映射到OFD输出,与保留的设备四色对象使用相同显示换算 +// 入参: space 合成空间, values 非预乘分量 +// 返回: [3]float64 显示颜色, error 校准颜色转换错误 +func pdfCompositeOutputColor(space *pdfgo.ColorSpace, values [4]float64) ([3]float64, error) { + if space.Model == "DeviceCMYK" && !space.Calibrated() { + black := 1 - values[3] + return [3]float64{(1 - values[0]) * black, (1 - values[1]) * black, (1 - values[2]) * black}, nil + } + return space.RGB(values[:space.Components()], "RelativeColorimetric") +} + +// pdfNormalBlend 判断混合模式是否为普通覆盖 +// 入参: mode 混合模式 +// 返回: bool 是否普通覆盖 +func pdfNormalBlend(mode pdfgo.Name) bool { + return mode == "" || mode == "Normal" || mode == "Compatible" +} diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go new file mode 100644 index 0000000..3ef1f97 --- /dev/null +++ b/ofdgo_pdf_composite_draw.go @@ -0,0 +1,480 @@ +// 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" +) + +// draw 按原始顺序在当前颜色空间绘制图元及透明组 +// 入参: nodes 图元, pixels 背景与输出像素, space 混合空间 +// 返回: error 几何或颜色错误 +func (c *pdfCompositor) draw(nodes []pdfCompositeNode, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error { + for _, node := range nodes { + if err := c.importer.ctx.Err(); err != nil { + return err + } + if node.group != nil { + if err := c.drawGroup(node, pixels, space); err != nil { + return err + } + continue + } + if err := c.drawMark(node, pixels, space); err != nil { + return err + } + } + return nil +} + +// drawMark 将填充与描边作为同一对象,保留重叠区域的挖空和叠印语义 +// 入参: node 图元, pixels 背景与输出像素, space 混合空间 +// 返回: error 几何或颜色错误 +func (c *pdfCompositor) drawMark(node pdfCompositeNode, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error { + style, fill, stroke := node.style() + mask, err := c.mask(style.SoftMask, space) + if err != nil { + return err + } + var initial []pdfCompositePixel + result := pixels + grouped := fill && stroke && (style.FillOverprint || style.StrokeOverprint) && style.Fill.Alpha == style.Stroke.Alpha + if fill && stroke { + initial = append([]pdfCompositePixel(nil), pixels...) + if grouped { + result = append([]pdfCompositePixel(nil), pixels...) + for i := range result { + result[i].effect = 0 + } + } + } + for _, outline := range []bool{false, true} { + if outline && !stroke || !outline && !fill { + continue + } + paint, overprint := style.Fill, style.FillOverprint + if outline { + paint, overprint = style.Stroke, style.StrokeOverprint + } + mode, paintMask := style.BlendMode, mask + var knockout []pdfCompositePixel + if grouped { + paint.Alpha, mode, paintMask = 1, "Normal", nil + } else if outline { + knockout = initial + } + if err := c.drawPaint(node, outline, paint, mode, overprint, paintMask, knockout, result, space); err != nil { + return err + } + } + if grouped { + return pdfCompositeGroup(pixels, initial, result, space, space, style.Fill.Alpha, mask, style.BlendMode) + } + return nil +} + +// drawPaint 结合几何覆盖与源颜色绘制一个填充或描边 +// 入参: node 图元, outline 描边标志, paint 画刷, mode 混合模式, overprint 叠印标志, mask 蒙版, initial 挖空初始背景, pixels 输出, space 混合空间 +// 返回: error 几何或颜色错误 +func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdfgo.Paint, mode pdfgo.Name, overprint bool, mask []float64, initial, pixels []pdfCompositePixel, space *pdfgo.ColorSpace) error { + coverage, err := c.coverage(node, outline) + if err != nil || coverage == nil { + return err + } + 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 + var uniformValues [4]float64 + uniformReady := false + for y := 0; y < c.height; y++ { + if err := c.importer.ctx.Err(); err != nil { + return err + } + for x := 0; x < c.width; x++ { + _, _, _, a := coverage.At(x, y).RGBA() + if a == 0 { + continue + } + index := y*c.width + x + shape, opacity := float64(a)/65535, paint.Alpha + if mask != nil { + opacity *= mask[index] + } + var values [4]float64 + if uniform { + if !uniformReady { + uniformValues, _, err = pdfCompositeColor(paint, pdfgo.Point{}, space, style.RenderingIntent) + uniformReady = true + } + values = uniformValues + } else { + point := c.inverse.Apply(pdfgo.Point{X: c.box.X + (float64(x)+.5)*step, Y: c.box.Y + (float64(y)+.5)*step}) + if node.image != nil { + var alpha float64 + values, alpha, err = c.imageColor(*node.image, point, space) + opacity *= alpha + } else { + var visible bool + values, visible, err = pdfCompositeColor(paint, point, space, style.RenderingIntent) + if !visible { + shape = 0 + } + } + } + if err != nil { + return err + } + 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 + } + pdfCompositeInterpolate(&pixels[index], pixel, shape) + } else if err := pdfCompositePaintOver(&pixels[index], values, shape*opacity, space, mode, compatible); err != nil { + return err + } + } + } + return nil +} + +// pdfCompositeInterpolate 按形状覆盖混合预乘颜色及累计透明度 +// 入参: target 原像素与输出, source 替换像素, shape 覆盖率 +func pdfCompositeInterpolate(target *pdfCompositePixel, source pdfCompositePixel, shape float64) { + alpha := target.alpha*(1-shape) + source.alpha*shape + if alpha != 0 { + for i := range target.values { + target.values[i] = (target.values[i]*target.alpha*(1-shape) + source.values[i]*source.alpha*shape) / alpha + } + } + target.alpha = alpha + target.effect = target.effect*(1-shape) + source.effect*shape +} + +// drawGroup 按隔离标志保留初始背景,移除背景贡献后应用组透明度 +// 入参: node 透明组, pixels 父组像素, parent 父混合空间 +// 返回: error 颜色或合成错误 +func (c *pdfCompositor) drawGroup(node pdfCompositeNode, pixels []pdfCompositePixel, parent *pdfgo.ColorSpace) error { + g := node.group + space := g.ColorSpace + if space == nil { + space = parent + } + initial := make([]pdfCompositePixel, len(pixels)) + if !g.Isolated { + for i, pixel := range pixels { + values, err := space.Convert(pixel.values[:parent.Components()], parent, "RelativeColorimetric") + if err != nil { + return err + } + initial[i] = pdfCompositePixel{values: values, alpha: pixel.alpha} + } + } + result := append([]pdfCompositePixel(nil), initial...) + if err := c.draw(node.children, result, space); err != nil { + return err + } + mask, err := c.mask(g.SoftMask, parent) + if err != nil { + return err + } + return pdfCompositeGroup(pixels, initial, result, space, parent, g.Alpha, mask, g.BlendMode) +} + +// 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 { + if pixel.effect == 0 { + continue + } + values := pixel.values + for j := 0; j < space.Components(); j++ { + values[j] = math.Max(0, math.Min(1, (pixel.values[j]*pixel.alpha-initial[i].values[j]*initial[i].alpha*(1-pixel.effect))/pixel.effect)) + } + values, err := parent.Convert(values[:space.Components()], space, "RelativeColorimetric") + if err != nil { + return err + } + alpha := pixel.effect * opacity + if mask != nil { + alpha *= mask[i] + } + if err := pdfCompositeOver(&pixels[i], values, alpha, parent, mode); err != nil { + return err + } + } + return nil +} + +// mask 在组颜色空间计算蒙版,保留背景色、组隔离和亮度传递 +// 入参: mask 蒙版或空值, inherited 未指定蒙版空间时继承的空间 +// 返回: []float64 蒙版透明度或空值, error 蒙版错误 +func (c *pdfCompositor) mask(mask *pdfgo.SoftMask, inherited *pdfgo.ColorSpace) ([]float64, error) { + if mask == nil { + return nil, nil + } + if cached, ok := c.masks[mask]; ok { + return cached, nil + } + nodes, ok := c.cache.masks[mask] + var err error + if !ok { + nodes, err = pdfCompositeNodes(mask.Walk, c.importer.warning) + if err != nil { + return nil, err + } + c.cache.masks[mask] = nodes + } + space := mask.ColorSpace + if space == nil { + space = inherited + } + pixels := make([]pdfCompositePixel, c.width*c.height) + if mask.Subtype == "Luminosity" { + for i := range pixels { + copy(pixels[i].values[:], mask.Backdrop) + pixels[i].alpha = 1 + } + } + if err := c.draw(nodes, pixels, space); err != nil { + return nil, err + } + result := make([]float64, len(pixels)) + for i, pixel := range pixels { + v := pixel.alpha + if mask.Subtype == "Luminosity" { + v, err = space.Luminosity(pixel.values[:space.Components()], "RelativeColorimetric") + if err != nil { + return nil, err + } + } + result[i] = math.Max(0, math.Min(1, mask.Transfer[0]+v*(mask.Transfer[1]-mask.Transfer[0]))) + } + c.masks[mask] = result + return result, nil +} + +// imageColor 采样映射后的原始图像分量,插值在源空间内进行 +// 入参: mark 图像, point 页面坐标, space 混合空间 +// 返回: [4]float64 源颜色, float64 图像透明度, error 解码或变换错误 +func (c *pdfCompositor) imageColor(mark pdfgo.ImageMark, point pdfgo.Point, space *pdfgo.ColorSpace) ([4]float64, float64, error) { + source := c.cache.images[mark.Image] + if source == nil { + var err error + source, err = mark.Image.DecodeComponents() + if err != nil { + return [4]float64{}, 0, err + } + c.cache.images[mark.Image] = source + } + inverse, ok := mark.Matrix.Inverse() + if !ok { + return [4]float64{}, 0, fmt.Errorf("invalid PDF image matrix") + } + point = inverse.Apply(point) + if point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 { + return [4]float64{}, 0, nil + } + x, y := point.X*float64(source.Rect.Dx())-.5, (1-point.Y)*float64(source.Rect.Dy())-.5 + sample := func(x, y int) ([4]float64, float64) { + return source.ValuesAt(max(0, min(source.Rect.Dx()-1, x)), max(0, min(source.Rect.Dy()-1, y))) + } + values, alpha := sample(int(math.Floor(x+.5)), int(math.Floor(y+.5))) + if mark.Image.Interpolate { + ix, iy := int(math.Floor(x)), int(math.Floor(y)) + fx, fy := x-float64(ix), y-float64(iy) + values, alpha = [4]float64{}, 0 + for j := 0; j < 2; j++ { + for i := 0; i < 2; i++ { + weight := (1 - fx) * (1 - fy) + if i == 1 { + weight = fx * (1 - fy) + } + if j == 1 { + weight = (1 - fx) * fy + if i == 1 { + weight = fx * fy + } + } + v, a := sample(ix+i, iy+j) + alpha += a * weight + for k := 0; k < source.Space.Components(); k++ { + values[k] += v[k] * a * weight + } + } + } + if alpha != 0 { + for k := range values { + values[k] = math.Max(0, math.Min(1, values[k]/alpha)) + } + } + } + if mark.Image.ImageMask { + color, visible, err := pdfCompositeColor(mark.Style.Fill, mark.Matrix.Apply(point), space, mark.Style.RenderingIntent) + if !visible { + return color, 0, err + } + return color, alpha * (1 - values[0]), err + } + values, err := space.Convert(values[:source.Space.Components()], source.Space, mark.Style.RenderingIntent) + return values, alpha, err +} + +// pdfCompositeColor 将原始画刷变换到混合空间,渐变在源空间求值 +// 入参: paint 画刷, point 页面坐标, space 混合空间, intent 渲染意图 +// 返回: [4]float64 混合分量, bool 是否着色, error 颜色错误 +func pdfCompositeColor(paint pdfgo.Paint, point pdfgo.Point, space *pdfgo.ColorSpace, intent pdfgo.Name) ([4]float64, bool, error) { + if paint.Tiling != nil { + return [4]float64{}, false, &pdfgo.UnsupportedError{Feature: "local tiling pattern compositing"} + } + var values [4]float64 + var source *pdfgo.ColorSpace + if paint.Axial != nil || paint.Radial != nil { + t, visible := pdfShadingPosition(paint, point) + if !visible { + return values, false, nil + } + var err error + if g := paint.Axial; g != nil { + values, err = g.ValuesAt(t) + source, intent = g.Space, g.Intent + } else { + g := paint.Radial + values, err = g.ValuesAt(t) + source, intent = g.Space, g.Intent + } + if err != nil { + return values, false, err + } + } else if paint.Space != nil { + values, source = paint.Values, paint.Space + } else if paint.CMYK != nil { + values, source = *paint.CMYK, &pdfgo.ColorSpace{Model: "DeviceCMYK"} + } else { + copy(values[:], paint.RGB[:]) + source = &pdfgo.ColorSpace{Model: "DeviceRGB"} + } + values, err := space.Convert(values[:source.Components()], source, intent) + return values, true, err +} + +// pdfCompositePaintOver 以兼容叠印计算零色料分量,再应用对象混合模式 +// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式, compatible 是否保留零色料背景 +// 返回: error 未支持的混合模式 +func pdfCompositePaintOver(backdrop *pdfCompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name, compatible bool) error { + if compatible { + for i := range source { + if source[i] == 0 { + source[i] = backdrop.values[i] * backdrop.alpha + } + } + } + return pdfCompositeOver(backdrop, source, alpha, space, mode) +} + +// pdfCompositeOver 按PDF透明模型合成源颜色,四色混合使用分量补色 +// 入参: backdrop 背景及输出, source 源分量, alpha 源透明度, space 混合空间, mode 混合模式 +// 返回: error 未支持的混合模式 +func pdfCompositeOver(backdrop *pdfCompositePixel, source [4]float64, alpha float64, space *pdfgo.ColorSpace, mode pdfgo.Name) error { + if alpha == 0 { + return nil + } + combined := alpha + backdrop.alpha*(1-alpha) + for i := 0; i < space.Components(); i++ { + b, s := backdrop.values[i], source[i] + if space.Model == "DeviceCMYK" { + b, s = 1-b, 1-s + } + mixed, err := pdfSeparableBlend(b, s, mode) + if err != nil { + return err + } + if space.Model == "DeviceCMYK" { + mixed = 1 - mixed + } + backdrop.values[i] = math.Max(0, math.Min(1, ((1-alpha)*backdrop.alpha*backdrop.values[i]+alpha*((1-backdrop.alpha)*source[i]+backdrop.alpha*mixed))/combined)) + } + backdrop.alpha = combined + backdrop.effect = alpha + backdrop.effect*(1-alpha) + return nil +} + +// pdfSeparableBlend 计算标准可分离混合函数 +// 入参: b 背景分量, s 源分量, mode 混合模式 +// 返回: float64 混合分量, error 未支持的模式 +func pdfSeparableBlend(b, s float64, mode pdfgo.Name) (float64, error) { + switch mode { + case "", "Normal", "Compatible": + return s, nil + case "Multiply": + return b * s, nil + case "Screen": + return b + s - b*s, nil + case "Overlay": + if b <= .5 { + return 2 * b * s, nil + } + return 1 - 2*(1-b)*(1-s), nil + case "Darken": + return math.Min(b, s), nil + case "Lighten": + return math.Max(b, s), nil + case "ColorDodge": + if b == 0 { + return 0, nil + } + if s == 1 { + return 1, nil + } + return math.Min(1, b/(1-s)), nil + case "ColorBurn": + if b == 1 { + return 1, nil + } + if s == 0 { + return 0, nil + } + return 1 - math.Min(1, (1-b)/s), nil + case "HardLight": + if s <= .5 { + return 2 * b * s, nil + } + return 1 - 2*(1-b)*(1-s), nil + case "SoftLight": + if s <= .5 { + return b - (1-2*s)*b*(1-b), nil + } + d := math.Sqrt(b) + if b <= .25 { + d = ((16*b-12)*b + 4) * b + } + return b + (2*s-1)*(d-b), nil + case "Difference": + return math.Abs(b - s), nil + case "Exclusion": + return b + s - 2*b*s, nil + default: + return 0, &pdfgo.UnsupportedError{Feature: "nonseparable blend mode " + string(mode)} + } +} diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 5ec308a..546e390 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -15,23 +15,50 @@ package ofdgo import ( + "bytes" + "errors" "fmt" + "image" + "image/png" "math" "reflect" "github.com/xiaoqidun/pdfgo" ) -// group 将不透明图元的透明组转换为互不重叠的填充区域,保留矢量与组不透明度 +// errPDFGroupRaster 表示透明组需要按指定精度局部合成 +var errPDFGroupRaster = errors.New("PDF transparency group requires local compositing") + +// pdfPaintRegion 保存透明组内的单色填充区域 +type pdfPaintRegion struct { + paint pdfgo.Paint + path GeometryPath +} + +// group 将单色图元的透明组转换为互不重叠的填充区域,保留矢量与组不透明度 // 入参: mark 组边界状态, walk 组内容访问函数, visitor 页面访问器 // 返回: error 不支持的组内容或转换错误 func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error, visitor pdfgo.Visitor) error { + if mark.BlendMode != "" && mark.BlendMode != "Normal" && mark.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "group blend mode " + string(mark.BlendMode)} + } if err := p.flushPath(); err != nil { return err } + if mark.ColorSpace != nil && !mark.ColorSpace.SRGBEquivalent() { + if mark.Alpha != 1 || mark.SoftMask != nil { + return &pdfgo.UnsupportedError{Feature: "non-sRGB group color space"} + } + if err := walk(pdfOpaqueVisitor(visitor)); err != nil { + return err + } + } if mark.SoftMask != nil { clip, err := p.maskClip(mark.SoftMask) if err != nil { + if p.rasterMaskAllowed(err) { + return p.rasterGroup(mark, walk) + } return err } original := walk @@ -45,21 +72,74 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error } return p.flushPath() } + err := p.groupPaths(mark, walk, visitor) + if errors.Is(err, errPDFGroupRaster) && p.warning != nil { + return p.rasterGroup(mark, walk) + } + return err +} + +// pdfOpaqueVisitor 验证组内没有透明混合,允许独立的已校准颜色保留为矢量 +// 入参: visitor 正式访问器,语义提示由正式遍历交付 +// 返回: pdfgo.Visitor 不透明性检查器 +func pdfOpaqueVisitor(visitor pdfgo.Visitor) pdfgo.Visitor { + check := func(s pdfgo.Style, fill, stroke bool) error { + if s.SoftMask != nil || fill && s.Fill.Alpha != 1 || stroke && s.Stroke.Alpha != 1 || s.BlendMode != "" && s.BlendMode != "Normal" && s.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "non-sRGB transparent group content"} + } + return nil + } + v := pdfgo.Visitor{ + Path: func(m pdfgo.PathMark) error { return check(m.Style, m.Fill, m.Stroke) }, + Text: func(m pdfgo.TextMark) error { + return check(m.Style, m.Mode%4 == 0 || m.Mode%4 == 2, m.Mode%4 == 1 || m.Mode%4 == 2) + }, + Image: func(m pdfgo.ImageMark) error { + if m.Image.ImageMask || m.Image.Mask != nil || m.Image.SoftMask != nil || m.Image.Stream.Dictionary["SMaskInData"] != nil { + return &pdfgo.UnsupportedError{Feature: "non-sRGB masked group image"} + } + return check(m.Style, true, false) + }, + } + if visitor.Warning != nil { + v.Warning = func(pdfgo.Diagnostic) {} + } + if visitor.MarkedContent != nil { + v.MarkedContent = func(pdfgo.MarkedContentMark) error { return nil } + } + v.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + if mark.Alpha != 1 || mark.SoftMask != nil || mark.BlendMode != "" && mark.BlendMode != "Normal" && mark.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "non-sRGB nested transparent group"} + } + return walk(v) + } + return v +} + +// groupPaths 将正常混合的透明路径组分解为标准矢量区域 +// 入参: mark 组状态, walk 内容访问函数, visitor 页面访问器 +// 返回: error 无法矢量表达或几何错误 +func (p *pdfImporter) groupPaths(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error, visitor pdfgo.Visitor) error { geometry, err := p.editor.Geometry() if err != nil { return err } - type paintRegion struct { - paint pdfgo.Paint - path GeometryPath - } - var regions []paintRegion + var regions []pdfPaintRegion var combined GeometryPath var paint *pdfgo.Paint var clips []pdfgo.Path + opaque := true scale := math.Hypot(p.matrix[0], p.matrix[1]) - collect := pdfgo.Visitor{Warning: visitor.Warning} + collect := pdfgo.Visitor{ + Warning: visitor.Warning, + Text: func(pdfgo.TextMark) error { return errPDFGroupRaster }, + Image: func(pdfgo.ImageMark) error { return errPDFGroupRaster }, + Group: func(pdfgo.GroupMark, func(pdfgo.Visitor) error) error { return errPDFGroupRaster }, + } collect.Path = func(path pdfgo.PathMark) error { + if path.Style.SoftMask != nil || path.Style.BlendMode != "" && path.Style.BlendMode != "Normal" && path.Style.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "masked or blended transparent group content"} + } shape, err := ParseGeometryPath(p.pathData(path.Path, Box{})) if err != nil { return err @@ -72,21 +152,28 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error if stroke { color, overprint = path.Style.Stroke, path.Style.StrokeOverprint } - if color.Alpha != 1 || color.Axial != nil || color.Radial != nil || overprint && pdfOverprintNeedsSeparation(color) { - return &pdfgo.UnsupportedError{Feature: "transparent or overprinted group content"} + if color.Tiling != nil || color.Axial != nil || color.Radial != nil || overprint && pdfOverprintNeedsSeparation(color) { + return fmt.Errorf("%w: patterned or overprinted group content", errPDFGroupRaster) } + if color.Alpha == 0 { + continue + } + opaque = opaque && color.Alpha == 1 if paint == nil { paint, clips = &color, path.Style.Clips } else if !reflect.DeepEqual(clips, path.Style.Clips) { return &pdfgo.UnsupportedError{Feature: "independently clipped transparent group"} - } else if !reflect.DeepEqual(*paint, color) { - regions = append(regions, paintRegion{*paint, combined}) + } else if color.Alpha != 1 || !reflect.DeepEqual(*paint, color) { + regions = append(regions, pdfPaintRegion{*paint, combined}) combined = nil paint = &color } outline := shape evenOdd := path.Path.EvenOdd if stroke { + if path.Style.LineWidth == 0 { + return fmt.Errorf("%w: zero-width group stroke", errPDFGroupRaster) + } strokeShape, strokeScale, tolerance := shape, scale, 0.001 var strokeTransform pdfgo.Matrix if path.StrokeMatrix != (pdfgo.Matrix{}) { @@ -133,18 +220,24 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error if len(combined) == 0 { return nil } - regions = append(regions, paintRegion{*paint, combined}) + regions = append(regions, pdfPaintRegion{*paint, combined}) + if !opaque { + regions, err = pdfCompositeRegions(geometry, regions) + if err != nil { + return err + } + } var covered GeometryPath for i := len(regions) - 1; i >= 0; i-- { region := regions[i] visible := region.path - if len(covered) > 0 { + if opaque && len(covered) > 0 { visible, err = geometry.Combine(visible, covered, GeometrySubtract) if err != nil { return err } } - if i > 0 { + if opaque && i > 0 { covered, err = geometry.Combine(covered, region.path, GeometryUnion) if err != nil { return err @@ -168,7 +261,7 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error if err != nil { return err } - region.paint.Alpha = mark.Alpha + region.paint.Alpha *= mark.Alpha fill, stroke := true, false objectClips, err := p.clips(clips, box) if err != nil { @@ -181,6 +274,205 @@ func (p *pdfImporter) group(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error return nil } +// rasterGroup 在透明背景上合成局部图元,组透明度应用于合成结果,保留组外对象 +// 入参: mark 组状态, walk 内容访问函数 +// 返回: error 转换或渲染错误 +func (p *pdfImporter) rasterGroup(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { + img, box, err := p.renderGroup(func(local *pdfImporter) error { return walk(local.visitor()) }, nil) + if err != nil || img == nil { + return err + } + if mark.SoftMask != nil { + img, err = p.applySoftMask(img, box, mark.SoftMask) + if err != nil { + return err + } + } + return p.appendRasterGroup(img, box, mark.Alpha) +} + +// visitor 绑定当前导入器的图元与透明组访问函数 +// 返回: pdfgo.Visitor 页面访问器 +func (p *pdfImporter) visitor() pdfgo.Visitor { + v := pdfgo.Visitor{Path: p.path, Text: p.text, Image: p.image, Warning: p.warning} + v.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { return p.group(mark, walk, v) } + return v +} + +// renderGroup 按统一像素网格渲染局部内容,保留裁剪及后端选择 +// 入参: build 局部图元构建函数, viewport 指定裁取区域,nil时按图元边界计算 +// 返回: image.Image 透明图像, Box 页面区域, error 构建或渲染错误 +func (p *pdfImporter) renderGroup(build func(*pdfImporter) error, viewport *Box) (image.Image, Box, error) { + scene, box, err := p.compileGroup(build) + if err != nil || scene == nil && viewport == nil { + return nil, box, err + } + if viewport != nil { + box = *viewport + } + if scene == nil { + scene = &RasterPage{DPI: p.rasterDPI} + } + img, err := p.renderGroupScene(scene, box) + return img, box, err +} + +// compileGroup 编译局部图元并计算页面内的像素对齐边界 +// 入参: build 局部图元构建函数 +// 返回: *RasterPage 中立绘制场景, Box 页面区域, error 编译错误 +func (p *pdfImporter) compileGroup(build func(*pdfImporter) error) (*RasterPage, Box, error) { + var box Box + if err := p.ctx.Err(); err != nil { + return nil, box, err + } + editor := NewEditor() + editor.SetRenderBackends(p.editor.Backends()) + editor.SetFontDirs(p.editor.fontDirs...) + editor.SetFontFS(p.editor.fontFS...) + if _, err := editor.AddPage(p.pageWidth, p.pageHeight); err != nil { + return nil, box, err + } + local := pdfImporter{ctx: p.ctx, reader: p.reader, editor: editor, renderer: p.renderer, matrix: p.matrix, pageBox: p.pageBox, pageWidth: p.pageWidth, pageHeight: p.pageHeight, rasterDPI: p.rasterDPI, warning: p.warning, rasterWarned: true, fontIDs: map[*pdfgo.Font]string{}, fontMetrics: map[string]FontMetrics{}} + if err := build(&local); err != nil { + return nil, box, err + } + if err := local.flushPath(); err != nil { + return nil, box, err + } + if len(local.objects) == 0 { + return nil, box, nil + } + if err := local.commitObjects(); err != nil { + return nil, box, err + } + reader, err := editor.Reader() + if err != nil { + return nil, box, err + } + defer reader.Close() + page, err := reader.PageContentByIndex(0) + if err != nil { + return nil, box, err + } + renderer := p.renderer.childRenderer(reader) + renderer.TransparentBackground = true + for _, layer := range page.Content.Layer { + for _, object := range layer.Objects { + bounds, err := renderer.ObjectBounds(object, layer.DrawParam) + if err != nil { + return nil, box, err + } + box = unionTextBox(box, bounds) + } + } + if box.W <= 0 || box.H <= 0 { + return nil, box, nil + } + step := 25.4 / p.rasterDPI + x, y := math.Max(0, math.Floor(box.X/step)*step), math.Max(0, math.Floor(box.Y/step)*step) + xend, yend := math.Ceil(math.Min(p.pageWidth, box.X+box.W)/step)*step, math.Ceil(math.Min(p.pageHeight, box.Y+box.H)/step)*step + box = Box{X: x, Y: y, W: xend - x, H: yend - y} + if box.W <= 0 || box.H <= 0 { + return nil, box, nil + } + scene, err := renderer.CompilePage(page) + return scene, box, err +} + +// renderGroupScene 在指定区域渲染已编译场景,不修改缓存的命令与裁剪 +// 入参: source 原始场景, box 页面裁取区域 +// 返回: image.Image 透明图像, error 后端渲染错误 +func (p *pdfImporter) renderGroupScene(source *RasterPage, box Box) (image.Image, error) { + scene := *source + scene.Commands = append([]RasterCommand(nil), source.Commands...) + scene.Width, scene.Height = box.W, box.H + for i := range scene.Commands { + command := &scene.Commands[i] + command.Transform[4] -= box.X + command.Transform[5] -= box.Y + if command.Clip != nil { + command.Clip = append([]RasterSegment{}, command.Clip...) + for j := range command.Clip { + segment := &command.Clip[j] + segment.End.X, segment.End.Y = segment.End.X-box.X, segment.End.Y-box.Y + segment.Control1.X, segment.Control1.Y = segment.Control1.X-box.X, segment.Control1.Y-box.Y + segment.Control2.X, segment.Control2.Y = segment.Control2.X-box.X, segment.Control2.Y-box.Y + } + } + } + backend := p.editor.Backends().Raster + if backend == nil { + return nil, fmt.Errorf("PDF local compositing: %w", ErrBackendUnavailable) + } + return backend.Render(&scene) +} + +// appendRasterGroup 保存局部合成结果并记录输出精度 +// 入参: img 透明图像, box 页面区域, opacity 组透明度 +// 返回: error 保存错误 +func (p *pdfImporter) appendRasterGroup(img image.Image, box Box, opacity float64) error { + var data bytes.Buffer + if err := png.Encode(&data, img); err != nil { + return err + } + if err := p.ctx.Err(); err != nil { + return err + } + id, err := p.editor.AddImage(data.Bytes()) + if err != nil { + return err + } + alpha := int(math.Round(opacity * 255)) + object := ImageObject{ResourceID: id, Boundary: pdfBoundary(box), CTM: pdfNumbers(box.W, 0, 0, box.H, 0, 0), Alpha: &alpha} + p.objects = append(p.objects, GraphicObject{Type: "ImageObject", ImageObject: object}) + p.report.ImageObjects++ + if !p.rasterWarned { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF effect locally composited at %g DPI; other objects retained", p.rasterDPI)}) + p.rasterWarned = true + } + return nil +} + +// pdfCompositeRegions 按正常混合计算互不重叠的区域,组透明度由调用方统一应用 +// 入参: geometry 几何后端, regions 按绘制顺序排列的区域 +// 返回: []pdfPaintRegion 合成区域, error 几何错误 +func pdfCompositeRegions(geometry GeometryBackend, regions []pdfPaintRegion) ([]pdfPaintRegion, error) { + var result []pdfPaintRegion + for _, source := range regions { + remaining := source.path + var next []pdfPaintRegion + for _, backdrop := range result { + outside, err := geometry.Combine(backdrop.path, source.path, GeometrySubtract) + if err != nil { + return nil, err + } + if len(outside) != 0 { + next = append(next, pdfPaintRegion{backdrop.paint, outside}) + } + overlap, err := geometry.Combine(backdrop.path, source.path, GeometryIntersect) + if err != nil { + return nil, err + } + if len(overlap) != 0 { + paint := pdfgo.Paint{Alpha: source.paint.Alpha + backdrop.paint.Alpha*(1-source.paint.Alpha)} + for i := range paint.RGB { + paint.RGB[i] = (source.paint.RGB[i]*source.paint.Alpha + backdrop.paint.RGB[i]*backdrop.paint.Alpha*(1-source.paint.Alpha)) / paint.Alpha + } + next = append(next, pdfPaintRegion{paint, overlap}) + } + remaining, err = geometry.Combine(remaining, backdrop.path, GeometrySubtract) + if err != nil { + return nil, err + } + } + if len(remaining) != 0 { + next = append(next, pdfPaintRegion{source.paint, remaining}) + } + result = next + } + return result, nil +} + // pdfClippedVisitor 为图元及嵌套组附加裁剪,保留未提供的访问能力 // 入参: visitor 图元访问器, clip 页面坐标裁剪 // 返回: pdfgo.Visitor 裁剪访问器 @@ -216,6 +508,9 @@ func pdfClippedVisitor(visitor pdfgo.Visitor, clip pdfgo.Path) pdfgo.Visitor { // 入参: mask PDF蒙版 // 返回: pdfgo.Path 页面坐标裁剪路径, error 不可精确转换或几何错误 func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { + if mask.Subtype != "Luminosity" || mask.ColorSpace == nil || mask.ColorSpace.Model != "DeviceGray" || mask.ColorSpace.Calibrated() { + return pdfgo.Path{}, &pdfgo.UnsupportedError{Feature: "nonbinary luminosity mask"} + } if clip, ok := p.maskClips[mask]; ok { return clip, nil } @@ -230,7 +525,11 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { } return v == 1, nil } - background, err := opacity(mask.Background) + luminosity, err := mask.ColorSpace.Luminosity(mask.Backdrop, "RelativeColorimetric") + if err != nil { + return pdfgo.Path{}, err + } + background, err := opacity(luminosity) if err != nil { return pdfgo.Path{}, err } @@ -249,14 +548,14 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { identity := pdfImporter{matrix: pdfgo.Identity()} var visitor pdfgo.Visitor visitor.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { - if mark.Alpha != 1 || mark.SoftMask != nil { + if mark.Alpha != 1 || mark.SoftMask != nil || !pdfNormalBlend(mark.BlendMode) || mark.ColorSpace != nil && !mark.ColorSpace.Equal(mask.ColorSpace) { return &pdfgo.UnsupportedError{Feature: "transparent mask group"} } return walk(visitor) } visitor.Path = func(mark pdfgo.PathMark) error { paint := mark.Style.Fill - if !mark.Fill || mark.Stroke || paint.Alpha != 1 || paint.CMYK != nil || paint.Axial != nil || paint.Radial != nil || paint.RGB[0] != paint.RGB[1] || paint.RGB[1] != paint.RGB[2] || mark.Style.SoftMask != nil { + if !mark.Fill || mark.Stroke || paint.Alpha != 1 || paint.CMYK != nil || paint.Axial != nil || paint.Radial != nil || paint.Tiling != nil || paint.Space != nil && paint.Space.Calibrated() || paint.RGB[0] != paint.RGB[1] || paint.RGB[1] != paint.RGB[2] || mark.Style.SoftMask != nil || !pdfNormalBlend(mark.Style.BlendMode) { return &pdfgo.UnsupportedError{Feature: "nonbinary mask graphic"} } visible, err := opacity(paint.RGB[0]) @@ -272,6 +571,9 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) { return err } for _, clip := range mark.Style.Clips { + if len(clip.Text) != 0 { + return &pdfgo.UnsupportedError{Feature: "text-clipped binary mask"} + } c, err := ParseGeometryPath(identity.pathData(clip, Box{})) if err != nil { return err diff --git a/ofdgo_pdf_mask.go b/ofdgo_pdf_mask.go new file mode 100644 index 0000000..0cec9a7 --- /dev/null +++ b/ofdgo_pdf_mask.go @@ -0,0 +1,64 @@ +// 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 ( + "errors" + "fmt" + "image" + "image/color" + "math" + + "github.com/xiaoqidun/pdfgo" +) + +// rasterMaskAllowed 仅对不可矢量表达的蒙版启用显式局部合成,格式错误仍返回 +// 入参: err 蒙版转换错误 +// 返回: bool 是否允许局部合成 +func (p *pdfImporter) rasterMaskAllowed(err error) bool { + var unsupported *pdfgo.UnsupportedError + return p.warning != nil && errors.As(err, &unsupported) +} + +// applySoftMask 按原始颜色分量计算蒙版,逐块应用透明度及亮度传递 +// 入参: source 源图像, box 页面区域, mask PDF软蒙版 +// 返回: image.Image 合成图像, error 蒙版解释或渲染错误 +func (p *pdfImporter) applySoftMask(source image.Image, box Box, mask *pdfgo.SoftMask) (image.Image, error) { + inverse, ok := p.matrix.Inverse() + if !ok { + return nil, fmt.Errorf("invalid PDF mask transform") + } + out := image.NewNRGBA(source.Bounds()) + w, h := out.Rect.Dx(), out.Rect.Dy() + step := 25.4 / p.rasterDPI + cache := p.compositingCache() + for y := 0; y < h; y += 256 { + for x := 0; x < w; x += 256 { + width, height := min(256, w-x), min(256, h-y) + c := pdfCompositor{importer: p, box: Box{X: box.X + float64(x)*step, Y: box.Y + float64(y)*step, W: float64(width) * step, H: float64(height) * step}, width: width, height: height, inverse: inverse, cache: cache, masks: map[*pdfgo.SoftMask][]float64{}} + alpha, err := c.mask(mask, &pdfgo.ColorSpace{Model: "DeviceRGB"}) + if err != nil { + return nil, err + } + for i, opacity := range alpha { + px, py := out.Rect.Min.X+x+i%width, out.Rect.Min.Y+y+i/width + pixel := color.NRGBAModel.Convert(source.At(px, py)).(color.NRGBA) + pixel.A = uint8(math.Round(float64(pixel.A) * opacity)) + out.SetNRGBA(px, py, pixel) + } + } + } + return out, nil +} diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 980f9c7..d3e0067 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -33,8 +33,8 @@ import ( // 入参: mark PDF图像绘制信息 // 返回: error 错误信息 func (p *pdfImporter) image(mark pdfgo.ImageMark) error { - if mark.Style.BlendMode == "Multiply" { - return &pdfgo.UnsupportedError{Feature: "multiply image outside annotation appearance"} + if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "image blend mode " + string(mark.Style.BlendMode)} } if err := p.flushPath(); err != nil { return err @@ -45,6 +45,11 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { var err error mark.Style, err = p.maskStyle(mark.Style) if err != nil { + if p.rasterMaskAllowed(err) { + mask := mark.Style.SoftMask + mark.Style.SoftMask = nil + return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Image(mark) }) + } return err } if mark.Style.FillOverprint { @@ -60,12 +65,10 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { if err != nil { return err } - filter, err := p.reader.Resolve(source.Stream.Dictionary["Filter"]) + jpegOriginal, err := source.JPEGFile() if err != nil { return err } - space, _ := source.ColorSpace.(pdfgo.Name) - jpegOriginal := filter == pdfgo.Name("DCTDecode") && len(source.Decode) == 0 && !source.ImageMask && (space == "DeviceGray" || space == "DeviceRGB") && source.Mask == nil && source.SoftMask == nil var decoded image.Image if len(jbig2Original) == 0 { decoded, err = source.DecodeImage() @@ -106,8 +109,8 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { var encoded bytes.Buffer if len(jbig2Original) != 0 { encoded.Write(jbig2Original) - } else if jpegOriginal { - encoded.Write(source.Stream.Data) + } else if len(jpegOriginal) != 0 { + encoded.Write(jpegOriginal) } else { if err := png.Encode(&encoded, decoded); err != nil { return err @@ -124,6 +127,13 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { p.imageIDs[source.Stream] = id } } + return p.appendImage(mark, id) +} + +// appendImage 定位已注册的图像资源,保留透明度与裁剪 +// 入参: mark 图像绘制信息, id 资源编号 +// 返回: error 裁剪错误 +func (p *pdfImporter) appendImage(mark pdfgo.ImageMark, id string) error { m := p.matrix.Mul(mark.Matrix).Mul(pdfgo.Matrix{1, 0, 0, -1, 0, 1}) box := pdfBounds([]pdfgo.Point{m.Apply(pdfgo.Point{}), m.Apply(pdfgo.Point{X: 1}), m.Apply(pdfgo.Point{Y: 1}), m.Apply(pdfgo.Point{X: 1, Y: 1})}) alpha := int(math.Round(mark.Style.Fill.Alpha * 255)) @@ -141,6 +151,9 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error { // 入参: mark PDF文字绘制信息 // 返回: error 错误信息 func (p *pdfImporter) text(mark pdfgo.TextMark) error { + if mark.Style.BlendMode != "" && mark.Style.BlendMode != "Normal" && mark.Style.BlendMode != "Compatible" { + return &pdfgo.UnsupportedError{Feature: "text blend mode " + string(mark.Style.BlendMode)} + } if mark.Font.Subtype == pdfgo.Name("Type3") { if mark.Clip != nil { return &pdfgo.UnsupportedError{Feature: "Type3 text clipping"} @@ -159,21 +172,26 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { } return p.flushPath() } - if mark.Style.BlendMode == "Multiply" { - return &pdfgo.UnsupportedError{Feature: "multiply text"} - } if err := p.flushPath(); err != nil { return err } var err error mark.Style, err = p.maskStyle(mark.Style) if err != nil { + if mark.Clip == nil && p.rasterMaskAllowed(err) { + mask := mark.Style.SoftMask + mark.Style.SoftMask = nil + return p.rasterGroup(pdfgo.GroupMark{Alpha: 1, SoftMask: mask}, func(v pdfgo.Visitor) error { return v.Text(mark) }) + } return err } paintMode := mark.Mode % 4 if (paintMode == 0 || paintMode == 2) && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || (paintMode == 1 || paintMode == 2) && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "text color separation overprint"} } + if p.warning != nil && ((paintMode == 0 || paintMode == 2) && pdfGradientError(mark.Style.Fill) != nil || (paintMode == 1 || paintMode == 2) && pdfGradientError(mark.Style.Stroke) != nil) { + return p.compositeRegion(nil, pdfCompositeNode{text: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) + } font := mark.Font embedded := len(font.Program) != 0 if embedded && font.ProgramType != "FontFile" && font.ProgramType != "FontFile2" && font.ProgramType != "OpenType" && font.ProgramType != "Type1C" && font.ProgramType != "CIDFontType0C" { @@ -290,7 +308,21 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { position += count path, err := outline.GlyphOutline(gid, object.Size) if err != nil { - return err + return fmt.Errorf("PDF font %s outline: %w", font.Name, err) + } + if diagnostics, ok := outline.(FontGlyphDiagnostics); ok { + if err := diagnostics.GlyphWarning(gid); err != nil { + if p.warning == nil { + return fmt.Errorf("PDF font %s glyph %d hinting: %w", font.Name, gid, err) + } + if p.fontWarnings == nil { + p.fontWarnings = make(map[string]bool) + } + if !p.fontWarnings[id] { + p.warning(pdfgo.Diagnostic{Message: fmt.Sprintf("PDF font %s has invalid hinting instructions; design outlines and original font retained", font.Name)}) + p.fontWarnings[id] = true + } + } } bounds, err := path.Bounds() if err != nil { @@ -309,13 +341,22 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { box := pdfBounds(points) fill, stroke, visible := paintMode == 0 || paintMode == 2, paintMode == 1 || paintMode == 2, paintMode != 3 if stroke { + strokeMatrix := mark.StrokeMatrix + if strokeMatrix == (pdfgo.Matrix{}) { + strokeMatrix = pdfgo.Identity() + } + sx, sy := math.Hypot(strokeMatrix[0], strokeMatrix[1]), math.Hypot(strokeMatrix[2], strokeMatrix[3]) + if math.Abs(sx-sy) > 1e-8*math.Max(1, sx) || math.Abs(strokeMatrix[0]*strokeMatrix[2]+strokeMatrix[1]*strokeMatrix[3]) > 1e-8*math.Max(1, sx*sy) { + return &pdfgo.UnsupportedError{Feature: "anisotropic text stroke"} + } scale := math.Sqrt(math.Abs(m[0]*m[3] - m[1]*m[2])) if scale == 0 || math.IsNaN(scale) || math.IsInf(scale, 0) { return &pdfgo.UnsupportedError{Feature: "degenerate text stroke matrix"} } - margin := mark.Style.LineWidth * unit / 2 * math.Max(1, mark.Style.MiterLimit) + lineWidth := mark.Style.LineWidth * math.Hypot(p.matrix[0], p.matrix[1]) * sx + margin := lineWidth / 2 * math.Max(1, mark.Style.MiterLimit) box = Box{box.X - margin, box.Y - margin, box.W + 2*margin, box.H + 2*margin} - object.LineWidth = mark.Style.LineWidth * unit / scale + object.LineWidth = lineWidth / scale object.LineWidthSet = object.LineWidth == 0 object.Join = []string{"Miter", "Round", "Bevel"}[mark.Style.Join] object.MiterLimit = mark.Style.MiterLimit @@ -339,9 +380,12 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { object.Fill = &fill object.Stroke = &stroke object.Visible = &visible - object.FillColor, err = p.paintColor(mark.Style.Fill, box) - if err != nil { - return err + object.FillColor = p.color(mark.Style.Fill) + if fill { + object.FillColor, err = p.paintColor(mark.Style.Fill, box) + if err != nil { + return err + } } object.Clips, err = p.clips(mark.Style.Clips, box) if err != nil { @@ -354,6 +398,9 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { *clipObject.Fill = true *clipObject.Stroke = false clipObject.Visible = nil + if p.clipTexts == nil { + p.clipTexts = make(map[*pdfgo.TextClip]TextObject) + } p.clipTexts[mark.Clip] = clipObject } p.objects = append(p.objects, GraphicObject{Type: "TextObject", TextObject: object}) diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go new file mode 100644 index 0000000..13d796a --- /dev/null +++ b/ofdgo_pdf_shading.go @@ -0,0 +1,129 @@ +// 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 ( + "math" + "slices" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfGradientSegments 将线性源颜色及设备四色的截断点表达为OFD分段 +// 入参: 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"} + } + 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) + } + } + } + } + appendStop(b.Position, b.RGB) + } + return segments, nil +} + +// pdfGradientError 检查渐变能否精确映射为OFD线性分段 +// 入参: paint 画刷 +// 返回: error 不可等价表达的渐变 +func pdfGradientError(paint pdfgo.Paint) error { + if paint.Axial != nil { + _, err := pdfGradientSegments(paint.Axial.Stops, paint.Axial.Space) + return err + } + if paint.Radial != nil { + _, err := pdfGradientSegments(paint.Radial.Stops, paint.Radial.Space) + return err + } + return nil +} + +// gradientPath 按原始函数合成渐变路径,保留填充描边的单一对象语义 +// 入参: mark 路径绘制信息 +// 返回: error 几何或着色错误 +func (p *pdfImporter) gradientPath(mark pdfgo.PathMark) error { + return p.compositeRegion(nil, pdfCompositeNode{path: &mark}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) +} + +// pdfShadingPosition 计算渐变参数,未延伸区域及不存在非负圆半径的位置不着色 +// 入参: paint 渐变画刷, point 页面坐标 +// 返回: float64 归一化参数, bool 是否着色 +func pdfShadingPosition(paint pdfgo.Paint, point pdfgo.Point) (float64, bool) { + if g := paint.Axial; g != nil { + dx, dy := g.End.X-g.Start.X, g.End.Y-g.Start.Y + t := ((point.X-g.Start.X)*dx + (point.Y-g.Start.Y)*dy) / (dx*dx + dy*dy) + return t, finite(t) && (t >= 0 || g.Extend[0]) && (t <= 1 || g.Extend[1]) + } + g := paint.Radial + 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 + roots := [2]float64{math.NaN(), math.NaN()} + if a == 0 { + if b != 0 { + roots[0] = -c / b + } + } else if discriminant := b*b - 4*a*c; discriminant >= 0 { + q := -.5 * (b + math.Copysign(math.Sqrt(discriminant), b)) + if q == 0 { + roots[0] = -b / (2 * a) + } else { + roots[0], roots[1] = q/a, c/q + } + } + t := math.Inf(-1) + for _, root := range roots { + if finite(root) && g.StartRadius+root*dr >= 0 && (root >= 0 || g.Extend[0]) && (root <= 1 || g.Extend[1]) && root > t { + t = root + } + } + return t, finite(t) +} diff --git a/ofdgo_renderer_font_canvas.go b/ofdgo_renderer_font_canvas.go index afa6f59..e4d1858 100644 --- a/ofdgo_renderer_font_canvas.go +++ b/ofdgo_renderer_font_canvas.go @@ -68,6 +68,13 @@ func (f canvasFontMetrics) GlyphOutline(glyph uint16, size float64) (GeometryPat return *geometryFromCanvasPath(path), nil } +// GlyphWarning 返回字形指令栈下溢后保留设计轮廓的提示,原始字体不变 +// 入参: glyph 字形编号 +// 返回: error 恢复原因,无恢复时为空 +func (f canvasFontMetrics) GlyphWarning(glyph uint16) error { + return f.outlines.glyphWarning(glyph) +} + // GlyphOutlines 批量提取字形轮廓,重复编号只解析一次 // 入参: glyphs 字形编号, size 毫米字号 // 返回: []GeometryPath 同序只读轮廓, error 轮廓错误