Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ pixel as Chrome and WeasyPrint do; WeasyPrint's `--dpi` is the same lever. A
relative `src` resolves against `BaseURL`; an image that fails to fetch or
decode is left out.

List markers are real text in the PDF: the engine (v0.18.0 and later) lays
out each list item's `::marker` with its text — "•", "3.", "iv." — and
html2pdf draws it where the layout put it, so a marker is selectable and
extractable like the item's words. `list-style-type` (`disc`, `circle`,
`square`, `decimal`, `decimal-leading-zero`, `lower-`/`upper-alpha`,
`lower-`/`upper-roman`, a `<string>`, `none`), `list-style-position`
(`outside`, `inside`), `::marker` colour and `content`, and `<ol start>`,
`reversed`, `<li value>` and `type` all come from the engine, as do
`::before`/`::after` with CSS escapes (`content: "\2022\00a0"`), `counter()`
and `counters()` over `counter-reset`/`counter-increment`/`counter-set`.

Inline-level decoration — a styled `<span>`, `<code>`, `<a>`… with a
background, border or padding — paints too, fragmented per line the way CSS
does (`box-decoration-break: slice`: the left border only on an element's
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/go-pdfkit/extract v0.9.0
github.com/go-pdfkit/pdfkit v0.20.0
github.com/go-pdfkit/reader v0.9.0
github.com/go-webengine/engine v0.17.3
github.com/go-webengine/engine v0.18.0
github.com/spf13/cobra v1.10.2
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ github.com/go-simd/highway v0.0.14 h1:O3HPG00qgLGlrQyYBlMGyLYsuzZrqHA4+XNptPjV5g
github.com/go-simd/highway v0.0.14/go.mod h1:t5XBPxqa10Lnj0frQS7Ur0JaN+/CckEmm5HXrqBDxXA=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-webengine/engine v0.17.3 h1:6/iLdKsrGpZQoIrV6MCAiKJnrQx796iVQczoudqgtJM=
github.com/go-webengine/engine v0.17.3/go.mod h1:YvHO2zF+o4g18+LkrxsdDSj2d0nc5PFoMYiTXSe47QA=
github.com/go-webengine/engine v0.18.0 h1:G7tNla0rNi9Yra/de/bTABhxaEoL7UmEBgkNL24Xpa0=
github.com/go-webengine/engine v0.18.0/go.mod h1:YvHO2zF+o4g18+LkrxsdDSj2d0nc5PFoMYiTXSe47QA=
github.com/go-webengine/esbuildsandbox v0.1.0 h1:1LdmX+0/yJarQlxZgJ7JIMu+Qy6P7Yzu4NFcX9+jFl8=
github.com/go-webengine/esbuildsandbox v0.1.0/go.mod h1:za8oj1vbDfVkTpHLeRk7JfEQjmJfj+GHNq4R47681vQ=
github.com/go-widgets/painter v0.16.0 h1:j8FFObisySQChY2JiXS7Di2Mo4cqufIc0PDUnoDPMiA=
Expand Down
65 changes: 65 additions & 0 deletions lists_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) the go-pdfkit/html2pdf authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package html2pdf

import (
"strings"
"testing"
)

// TestListMarkersEscapesCountersReachThePDF holds the three defects found on
// 2026-10-28 rendering a report: list markers not painted (with start= and
// list-style-type), CSS string escapes in content painted literally, and
// counter() painting nothing. Each must be text in the PDF, read back by the
// extractor — selectable, searchable — not only a shape on the page.
func TestListMarkersEscapesCountersReachThePDF(t *testing.T) {
r := exportPDF(t, `<html><head><style>
.esc { list-style: none } .esc li::before { content: "\2022\00a0" }
ol.c { counter-reset: item; list-style: none }
ol.c > li::before { content: counter(item) ". "; counter-increment: item }
ol.n { list-style: none } ol.n li::before { content: counters(list-item, ".") " " }
</style></head><body>
<ul><li>bullet</li></ul>
<ol start="3"><li>three</li><li>four</li></ol>
<ol style="list-style-type: upper-roman"><li>one</li><li>two</li></ol>
<ol style="list-style-position: inside" start="7"><li>inside</li></ol>
<ul class="esc"><li>escaped</li></ul>
<ol class="c"><li>first</li><li>second</li></ol>
<ol class="n"><li>outer<ol class="n"><li>inner</li></ol></li></ol>
</body></html>`)
// Compared without spaces: the extractor does not infer a space from the
// gap between two drawn words ("plain words" reads "plainwords" too).
text := pageText(t, r, 1)
flat := strings.Join(strings.Fields(text), "")
for _, want := range []string{
"\u2022bullet", // the ul's disc, as a glyph
"3.three", "4.four", // ol start=3
"I.one", "II.two", // upper-roman
"7.inside", // an inside marker: inline on the item's line
"\u2022escaped", // content: "\2022\00a0", escapes decoded
"1.first", "2.second", // content: counter(item) ". "
"1outer", "1.1inner", // counters() over nested lists
} {
if !strings.Contains(flat, want) {
t.Errorf("the PDF's text lacks %q:\n%s", want, text)
}
}
if strings.Contains(text, `\2022`) || strings.Contains(text, "counter(") {
t.Errorf("an escape or a counter() was painted literally:\n%s", text)
}
}

// A marker on a list item that a page break pushes to the next page is
// painted on that page, not the first.
func TestListMarkerFollowsItsItemAcrossPages(t *testing.T) {
r := exportPDF(t, `<html><body><ol><li>first</li><li style="break-before:page">second</li></ol></body></html>`)
if r.PageCount() != 2 {
t.Fatalf("PageCount = %d, want 2", r.PageCount())
}
if p1, p2 := pageText(t, r, 1), pageText(t, r, 2); !strings.Contains(p1, "1.") || strings.Contains(p1, "2.") ||
!strings.Contains(p2, "2.") || strings.Contains(p2, "1.") {
t.Errorf("page 1 %q, page 2 %q", p1, p2)
}
}
61 changes: 41 additions & 20 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func (e *exporter) paintBox(b *layout.Box) {
return
}
e.paintDecoration(b.Style, b.X, b.Y, b.W, b.H, true, true)
e.paintMarker(b.Marker)
for _, line := range b.Lines {
e.paintLine(line)
}
Expand Down Expand Up @@ -144,27 +145,47 @@ func (e *exporter) paintLine(line *layout.LineBox) {
if it.Text == "" || it.Style == nil {
continue
}
// The engine set this text in the family's face, and in the fallback
// face for the characters the family has no glyph for; walk the same
// runs with the same fonts, advancing by the widths the layout used.
st := it.Style
e.p.SetFillColor(toRGB(st.Color))
x, y := e.toPdf(it.X, it.Y+it.Ascent)
for _, run := range e.measure.Runs(it.Text, st.FontFamily, st.FontWeight, st.Italic) {
f := e.fonts.pick(st.FontFamily, st.Bold(), st.Italic)
if run.Fallback {
f = e.fonts.fallback(st.Bold(), st.Italic)
}
e.p.SetFont(f, st.FontSize*e.scale*pxToPt)
// Plain cmap text, not the shaped path: the engine measures
// each glyph's own advance with no kerning and its raster draws
// the same, so a kerned run here ended short of its layout
// slot and the gap read as a space ("15.2.1 ." on RFC 9110's
// table of contents). Kerning and ligatures belong in both
// places at once — the engine's measure first.
_ = e.p.Text(x, y, run.Text)
x += e.measure.Measure(run.Text, st.FontFamily, st.FontSize, st.FontWeight, st.Italic) * e.scale * pxToPt
e.text(it.Style, it.Text, it.X, it.Y+it.Ascent)
}
}

// paintMarker draws a list item's outside marker — "•", "3.", "iv." — as real
// text, from the text and position the engine's layout gave it (layout.Marker:
// every marker carries its Text, a bullet too). An inside marker is not here:
// the engine lays it out as the first inline item of the item's first line,
// which paintLine already draws.
func (e *exporter) paintMarker(m *layout.Marker) {
if m == nil || m.Text == "" || m.Style == nil || m.Style.Visibility != css.VisibilityVisible {
return
}
if m.TextY+m.Ascent <= e.pageTop || m.TextY >= e.pageBot {
return
}
e.text(m.Style, m.Text, m.TextX, m.TextY+m.Ascent)
}

// text draws one run of text with its baseline at (xPx, baselinePx). The
// engine set this text in the family's face, and in the fallback face for the
// characters the family has no glyph for (a bullet U+2022 in a face without
// it); walk the same runs with the same fonts, advancing by the widths the
// layout used.
func (e *exporter) text(st *css.Style, s string, xPx, baselinePx float64) {
e.p.SetFillColor(toRGB(st.Color))
x, y := e.toPdf(xPx, baselinePx)
for _, run := range e.measure.Runs(s, st.FontFamily, st.FontWeight, st.Italic) {
f := e.fonts.pick(st.FontFamily, st.Bold(), st.Italic)
if run.Fallback {
f = e.fonts.fallback(st.Bold(), st.Italic)
}
e.p.SetFont(f, st.FontSize*e.scale*pxToPt)
// Plain cmap text, not the shaped path: the engine measures
// each glyph's own advance with no kerning and its raster draws
// the same, so a kerned run here ended short of its layout
// slot and the gap read as a space ("15.2.1 ." on RFC 9110's
// table of contents). Kerning and ligatures belong in both
// places at once — the engine's measure first.
_ = e.p.Text(x, y, run.Text)
x += e.measure.Measure(run.Text, st.FontFamily, st.FontSize, st.FontWeight, st.Italic) * e.scale * pxToPt
}
}

Expand Down
Loading