feat(转换能力): 补全PDF字体、渐变与链接
build.yaml / build (push) Successful in 44s

This commit is contained in:
xiaoqidun committed 2026-09-25 16:10:27 +08:00
1 parent b85b3bc27d
commit bbc8d59bdb
11 files changed
+165 -39

No files matched your search

+20 -2
View File
@@ -4996,6 +4996,13 @@ function destinationPoint(page, x, y) {
return { x, y };
}
function sourcePoint(page, x, y) {
if (state.rotation === 90) return { x: y, y: page.height - x };
if (state.rotation === 180) return { x: page.width - x, y: page.height - y };
if (state.rotation === 270) return { x: page.width - y, y: x };
return { x, y };
}
async function navigateDestination(dest) {
if (document.body.hasAttribute("aria-busy")) return;
const index = state.doc.pages.findIndex(page => page.id === dest.pageID);
@@ -5008,11 +5015,22 @@ async function navigateDestination(dest) {
return;
}
const seq = state.openSeq, scale = state.scale;
let retained = null;
if (dest.omitLeft || dest.omitTop) {
const current = state.doc.pages[state.pageIndex], shell = pageShell(state.pageIndex)?.getBoundingClientRect();
if (current && shell) {
const viewer = el.viewerPanel.getBoundingClientRect();
retained = sourcePoint(current,
(viewer.left + pageSpace() - shell.left) / (MM_TO_PX * scale),
(viewer.top + pageBlockSpace() - shell.top) / (MM_TO_PX * scale));
}
}
await renderPage(index, { fit: false, scroll: false });
if (seq !== state.openSeq || state.pageIndex !== index) return;
const page = state.doc.pages[index], size = pageViewSize(page), space = pageSpace();
const width = el.viewerPanel.clientWidth - space * 2, height = el.viewerPanel.clientHeight - space * 2;
let left = dest.left, top = dest.top;
let left = dest.omitLeft && retained ? retained.x : dest.left;
let top = dest.omitTop && retained ? retained.y : dest.top;
if (dest.type === "Fit") {
fitHeight(false);
scrollToPage(index);
@@ -5028,7 +5046,7 @@ async function navigateDestination(dest) {
if (dest.right <= dest.left || dest.bottom <= dest.top) return;
setScale(Math.min(width / Math.abs(b.x - a.x), height / Math.abs(b.y - a.y)) / MM_TO_PX, false);
} else {
setScale(dest.zoom > 0 ? dest.zoom : scale, false);
setScale(!dest.omitZoom && dest.zoom > 0 ? dest.zoom : scale, false);
}
const point = destinationPoint(page, left, top);
if (dest.type === "FitR") {
+1 -1
View File
@@ -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-20260924162208-698614ef984e
github.com/xiaoqidun/pdfgo v0.0.0-20260925061819-c89bfccf349d
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
+2 -2
View File
@@ -60,8 +60,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-20260924162208-698614ef984e h1:17a5JOpYM6gwXZnhapgrXA6H7LhtGVrF0rpeiEORYUA=
github.com/xiaoqidun/pdfgo v0.0.0-20260924162208-698614ef984e/go.mod h1:/D0XHgfZ7xNhix95swpNaThpuqoJjVR5wIadogtR5pI=
github.com/xiaoqidun/pdfgo v0.0.0-20260925061819-c89bfccf349d h1:hjdo01d4EsiNe9l0JNyc+Bqfrvdj9XGG786EYxAx3yw=
github.com/xiaoqidun/pdfgo v0.0.0-20260925061819-c89bfccf349d/go.mod h1:/D0XHgfZ7xNhix95swpNaThpuqoJjVR5wIadogtR5pI=
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=
+2 -1
View File
@@ -529,7 +529,8 @@ func renderPage(args []js.Value) (any, error) {
}
if dest := link.Dest; dest != nil {
item["dest"] = map[string]any{"type": dest.Type, "pageID": dest.PageID, "left": dest.Left, "top": dest.Top,
"right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom}
"right": dest.Right, "bottom": dest.Bottom, "zoom": dest.Zoom,
"omitLeft": dest.OmitLeft, "omitTop": dest.OmitTop, "omitZoom": dest.OmitZoom}
}
links[i] = item
}
+24 -7
View File
@@ -39,13 +39,16 @@ type Goto struct {
// Dest 文档内跳转目标
type Dest struct {
Type string `xml:"Type,attr"`
PageID string `xml:"PageID,attr"`
Left float64 `xml:"Left"`
Right float64 `xml:"Right"`
Top float64 `xml:"Top"`
Bottom float64 `xml:"Bottom"`
Zoom float64 `xml:"Zoom"`
Type string `xml:"Type,attr"`
PageID string `xml:"PageID,attr"`
Left float64 `xml:"Left"`
Right float64 `xml:"Right"`
Top float64 `xml:"Top"`
Bottom float64 `xml:"Bottom"`
Zoom float64 `xml:"Zoom"`
OmitLeft bool `xml:"-"`
OmitTop bool `xml:"-"`
OmitZoom bool `xml:"-"`
}
// GotoBookmark 书签跳转目标
@@ -151,6 +154,17 @@ func (dest *Dest) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
}
dest.Type = attrValue(start, "Type")
dest.PageID = attrValue(start, "PageID")
dest.OmitLeft, dest.OmitTop, dest.OmitZoom = true, true, true
for _, attr := range start.Attr {
switch attr.Name.Local {
case "Left":
dest.OmitLeft = false
case "Top":
dest.OmitTop = false
case "Zoom":
dest.OmitZoom = false
}
}
dest.Left = actionFloatAttr(start, "Left")
dest.Right = actionFloatAttr(start, "Right")
dest.Top = actionFloatAttr(start, "Top")
@@ -161,18 +175,21 @@ func (dest *Dest) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
}
if value.Left != nil {
dest.Left = *value.Left
dest.OmitLeft = false
}
if value.Right != nil {
dest.Right = *value.Right
}
if value.Top != nil {
dest.Top = *value.Top
dest.OmitTop = false
}
if value.Bottom != nil {
dest.Bottom = *value.Bottom
}
if value.Zoom != nil {
dest.Zoom = *value.Zoom
dest.OmitZoom = false
}
return nil
}
+6
View File
@@ -372,6 +372,12 @@ func annotationLinkXML(target AnnotationLink) ([]byte, error) {
if !finite(value.value) {
return nil, fmt.Errorf("destination coordinates must be finite")
}
omit := dest.Type == "XYZ" && (value.name == "Left" && dest.OmitLeft || value.name == "Top" && dest.OmitTop || value.name == "Zoom" && dest.OmitZoom)
omit = omit || dest.Type == "FitH" && value.name == "Top" && dest.OmitTop
omit = omit || dest.Type == "FitV" && value.name == "Left" && dest.OmitLeft
if omit {
continue
}
if dest.Type == "XYZ" && slices.Contains([]string{"Left", "Top", "Zoom"}, value.name) || dest.Type == "FitH" && value.name == "Top" || dest.Type == "FitV" && value.name == "Left" || dest.Type == "FitR" && value.name != "Zoom" {
attrs.add(value.name, ofdNumber(value.value))
}
+23 -4
View File
@@ -378,8 +378,8 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
margin := mark.Style.LineWidth * scale / 2 * math.Max(1, mark.Style.MiterLimit)
box = Box{box.X - margin, box.Y - margin, box.W + 2*margin, box.H + 2*margin}
}
strokeColor := StrokeColor(*p.pathColor(mark.Style.Stroke, box))
object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: p.pathData(mark.Path, box), Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: p.pathColor(mark.Style.Fill, box), StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: p.clips(mark.Style.Clips, box)}
strokeColor := StrokeColor(*p.paintColor(mark.Style.Stroke, box))
object := PathObject{Boundary: pdfBoundary(box), AbbreviatedData: p.pathData(mark.Path, box), Fill: &mark.Fill, Stroke: &mark.Stroke, FillColor: p.paintColor(mark.Style.Fill, box), StrokeColor: &strokeColor, LineWidth: mark.Style.LineWidth * scale, Cap: []string{"Butt", "Round", "Square"}[mark.Style.Cap], Join: []string{"Miter", "Round", "Bevel"}[mark.Style.Join], MiterLimit: mark.Style.MiterLimit, Clips: p.clips(mark.Style.Clips, box)}
object.LineWidthSet = object.LineWidth == 0
if mark.Path.EvenOdd {
object.Rule = "Even-Odd"
@@ -398,11 +398,30 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error {
return nil
}
// pathColor 将页面渐变轴转换为对象局部坐标
// paintColor 将页面渐变转换为对象局部坐标
// 入参: paint PDF画刷, box 对象边界
// 返回: *FillColor OFD颜色或渐变
func (p *pdfImporter) pathColor(paint pdfgo.Paint, box Box) *FillColor {
func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) *FillColor {
color := p.color(paint)
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])
extend := 0
if gradient.Extend[0] {
extend |= 1
}
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)}
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))}})
}
color.Value = ""
color.RadialShd = shading
return color
}
if paint.Axial == nil {
return color
}
+2 -2
View File
@@ -72,7 +72,7 @@ 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 || overprint && pdfOverprintNeedsSeparation(color) {
if color.Alpha != 1 || color.Axial != nil || color.Radial != nil || overprint && pdfOverprintNeedsSeparation(color) {
return &pdfgo.UnsupportedError{Feature: "transparent or overprinted group content"}
}
if paint == nil {
@@ -230,7 +230,7 @@ func (p *pdfImporter) maskClip(mask *pdfgo.SoftMask) (pdfgo.Path, error) {
}
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.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.RGB[0] != paint.RGB[1] || paint.RGB[1] != paint.RGB[2] || mark.Style.SoftMask != nil {
return &pdfgo.UnsupportedError{Feature: "nonbinary mask graphic"}
}
visible, err := opacity(paint.RGB[0])
+58 -4
View File
@@ -41,7 +41,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
if annotation.Subtype != "Link" {
return &pdfgo.UnsupportedError{Feature: "annotation " + string(annotation.Subtype)}
}
for _, key := range []pdfgo.Name{"AP", "AA", "BS", "QuadPoints", "OC"} {
for _, key := range []pdfgo.Name{"AP", "AA", "BS", "OC"} {
if dict[key] != nil {
return &pdfgo.UnsupportedError{Feature: fmt.Sprintf("link annotation field %q", key)}
}
@@ -127,6 +127,7 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
dest := &Dest{Type: string(destination.Mode), PageID: p.pageIDs[destination.Page]}
if destination.Mode == "XYZ" {
values := [3]float64{}
retained := [3]bool{}
for n, v := range destination.Parameters {
switch v := v.(type) {
case pdfgo.Integer:
@@ -134,13 +135,16 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
case pdfgo.Real:
values[n] = float64(v)
case nil:
if n < 2 {
return &pdfgo.UnsupportedError{Feature: "retained destination coordinates"}
}
retained[n] = true
}
}
point := matrix.Apply(pdfgo.Point{X: values[0], Y: values[1]})
dest.Left, dest.Top, dest.Zoom = point.X, point.Y, values[2]
dest.OmitLeft, dest.OmitTop = retained[0], retained[1]
if targetPage.Rotation == 90 || targetPage.Rotation == 270 {
dest.OmitLeft, dest.OmitTop = retained[1], retained[0]
}
dest.OmitZoom = retained[2] || values[2] == 0
} else if destination.Mode != "Fit" {
return &pdfgo.UnsupportedError{Feature: "destination mode " + string(destination.Mode)}
}
@@ -154,9 +158,59 @@ func (p *pdfImporter) annotations(ctx context.Context, page *pdfgo.Page, strict
}
no := false
object.Fill, object.Stroke = &no, &no
action.Region, err = p.linkRegion(annotation, box)
if err != nil {
return err
}
object.Actions = []Action{action}
p.objects = append(p.objects, GraphicObject{Type: "PathObject", PathObject: object})
p.report.Links++
}
return nil
}
// linkRegion 将PDF链接四边形映射为OFD动作区域,越出注解矩形时使用矩形
// 入参: annotation PDF链接注解, box OFD对象边界
// 返回: *Region 点击区域, error 解析错误
func (p *pdfImporter) linkRegion(annotation pdfgo.Annotation, box Box) (*Region, error) {
value, err := p.reader.Resolve(annotation.Dictionary["QuadPoints"])
if err != nil || value == nil {
return nil, err
}
array, ok := value.(pdfgo.Array)
if !ok || len(array) == 0 || len(array)%8 != 0 {
return nil, nil
}
region := &Region{Area: make([]RegionArea, 0, len(array)/8)}
for start := 0; start < len(array); start += 8 {
var points [4]pdfgo.Point
for index := range points {
coordinates := [2]float64{}
for axis := range coordinates {
resolved, err := p.reader.Resolve(array[start+index*2+axis])
if err != nil {
return nil, err
}
switch number := resolved.(type) {
case pdfgo.Integer:
coordinates[axis] = float64(number)
case pdfgo.Real:
coordinates[axis] = float64(number)
default:
return nil, nil
}
}
x, y := coordinates[0], coordinates[1]
if !finite(x) || !finite(y) || x < annotation.Rect.XMin || x > annotation.Rect.XMax || y < annotation.Rect.YMin || y > annotation.Rect.YMax {
return nil, nil
}
points[index] = p.matrix.Apply(pdfgo.Point{X: x, Y: y})
}
area := RegionArea{Start: pdfNumbers(points[0].X-box.X, points[0].Y-box.Y)}
for _, point := range points[1:] {
area.Command = append(area.Command, RegionCommand{Type: "Line", Point1: pdfNumbers(point.X-box.X, point.Y-box.Y)})
}
region.Area = append(region.Area, area)
}
return region, nil
}
+12 -11
View File
@@ -39,7 +39,7 @@ func (p *pdfImporter) image(mark pdfgo.ImageMark) error {
if err := p.flushPath(); err != nil {
return err
}
if mark.Image.ImageMask && mark.Style.Fill.Axial != nil {
if mark.Image.ImageMask && (mark.Style.Fill.Axial != nil || mark.Style.Fill.Radial != nil) {
return &pdfgo.UnsupportedError{Feature: "gradient stencil image"}
}
var err error
@@ -147,9 +147,6 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
if err := p.flushPath(); err != nil {
return err
}
if mark.Style.Fill.Axial != nil || mark.Style.Stroke.Axial != nil {
return &pdfgo.UnsupportedError{Feature: "gradient text paint"}
}
var err error
mark.Style, err = p.maskStyle(mark.Style)
if err != nil {
@@ -167,7 +164,8 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
if id == "" {
var err error
if embedded {
program, err := pdfFontProgram(font)
var program []byte
program, err = pdfFontProgram(font)
if err != nil {
return err
}
@@ -225,7 +223,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
}
}
if embedded && gid >= metrics.NumGlyphs() {
return fmt.Errorf("PDF glyph index outside embedded font")
return fmt.Errorf("PDF glyph index %d outside embedded font %s (%d glyphs)", gid, font.Name, metrics.NumGlyphs())
}
if !embedded && glyph.Text == "" {
return &pdfgo.UnsupportedError{Feature: "unmapped external font character"}
@@ -263,8 +261,6 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
}
margin := mark.Style.LineWidth * unit / 2 * math.Max(1, mark.Style.MiterLimit)
box = Box{box.X - margin, box.Y - margin, box.W + 2*margin, box.H + 2*margin}
color := StrokeColor(*p.color(mark.Style.Stroke))
object.StrokeColor = &color
object.LineWidth = mark.Style.LineWidth * unit / scale
object.LineWidthSet = object.LineWidth == 0
object.Join = []string{"Miter", "Round", "Bevel"}[mark.Style.Join]
@@ -274,6 +270,10 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
}
}
object.Boundary = pdfBoundary(box)
if stroke {
color := StrokeColor(*p.paintColor(mark.Style.Stroke, box))
object.StrokeColor = &color
}
if box.X >= p.pageWidth || box.Y >= p.pageHeight || box.X+box.W <= 0 || box.Y+box.H <= 0 {
visible = false
}
@@ -281,7 +281,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
object.Fill = &fill
object.Stroke = &stroke
object.Visible = &visible
object.FillColor = p.color(mark.Style.Fill)
object.FillColor = p.paintColor(mark.Style.Fill, box)
object.Clips = p.clips(mark.Style.Clips, box)
p.objects = append(p.objects, GraphicObject{Type: "TextObject", TextObject: object})
p.report.TextObjects++
@@ -293,7 +293,8 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error {
// 返回: []byte 封装后的字体数据, error 错误信息
func pdfFontProgram(source *pdfgo.Font) ([]byte, error) {
program := source.Program
if source.ProgramType == "Type1C" || source.ProgramType == "CIDFontType0C" {
bareCFF := len(program) >= 4 && program[0] == 1 && program[1] == 0 && program[2] >= 4 && program[3] >= 1 && program[3] <= 4
if source.ProgramType == "Type1C" || source.ProgramType == "CIDFontType0C" || bareCFF {
var err error
program, _, err = wrapCFFToOTF(source.Program)
if err != nil {
@@ -377,7 +378,7 @@ func pdfFontProgram(source *pdfgo.Font) ([]byte, error) {
tables["OS/2"] = buildOS2TableWithMetrics(int16(binary.BigEndian.Uint16(hhea[4:6])), int16(binary.BigEndian.Uint16(hhea[6:8])))
changed = true
}
if !changed {
if !changed && len(program)%4 == 0 {
return program, nil
}
return serializeOTF(tables)
+15 -5
View File
@@ -961,13 +961,23 @@ func (x *ofdXML) actions(actions []Action) {
attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}}
switch dest.Type {
case "XYZ":
attrs.add("Left", ofdNumber(dest.Left))
attrs.add("Top", ofdNumber(dest.Top))
attrs.add("Zoom", ofdNumber(dest.Zoom))
if !dest.OmitLeft {
attrs.add("Left", ofdNumber(dest.Left))
}
if !dest.OmitTop {
attrs.add("Top", ofdNumber(dest.Top))
}
if !dest.OmitZoom {
attrs.add("Zoom", ofdNumber(dest.Zoom))
}
case "FitH":
attrs.add("Top", ofdNumber(dest.Top))
if !dest.OmitTop {
attrs.add("Top", ofdNumber(dest.Top))
}
case "FitV":
attrs.add("Left", ofdNumber(dest.Left))
if !dest.OmitLeft {
attrs.add("Left", ofdNumber(dest.Left))
}
case "FitR":
attrs.add("Left", ofdNumber(dest.Left))
attrs.add("Right", ofdNumber(dest.Right))