diff --git a/README.md b/README.md index 05e0440..915175e 100644 --- a/README.md +++ b/README.md @@ -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 ``, `none`), `list-style-position` +(`outside`, `inside`), `::marker` colour and `content`, and `
    `, +`reversed`, `
  1. ` 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 ``, ``, ``… 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 diff --git a/go.mod b/go.mod index cf1417c..e8cb509 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 5a9c7c4..8003bef 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/lists_test.go b/lists_test.go new file mode 100644 index 0000000..6d1b435 --- /dev/null +++ b/lists_test.go @@ -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, ` +
    • bullet
    +
    1. three
    2. four
    +
    1. one
    2. two
    +
    1. inside
    +
    • escaped
    +
    1. first
    2. second
    +
    1. outer
      1. inner
    +`) + // 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, `
    1. first
    2. second
    `) + 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) + } +} diff --git a/render.go b/render.go index 5228db8..4d9033a 100644 --- a/render.go +++ b/render.go @@ -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) } @@ -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 } }