Skip to content

Commit 500327d

Browse files
andiwandclaude
andauthored
PDF stage 4.10: tiling patterns (PatternType 1) (#574)
* PDF stage 4.10: tiling patterns (PatternType 1) Render `/PatternType 1` tiling patterns selected by `scn` in a `/Pattern` colour space, reusing the form-XObject content-stream machinery. - Parser: `parse_pattern` reads a tiling pattern's stream content, `/Resources`, `/BBox`, `/XStep`/`/YStep` and `/PaintType`. Patterns are memoized by reference (like XObjects) so a cycle through a tiling pattern's own `/Resources` resolves to the existing element instead of recursing. - Extractor: `paint_path` resolves a tiling pattern to `PathElement::fill_pattern` + the pattern `/Matrix`. `scn` now keeps the leading colour components for an uncoloured pattern (`/PaintType 2`), so its underlying fill colour is available. - HTML: a `PatternRegistry` runs the tile content as a mini page (`extract_page`) and emits an SVG `<pattern>` repeating it every `/XStep`/`/YStep`, with `patternTransform` placing the lattice; the fill references it as `fill="url(#…)"`. Uncoloured cells are painted in the path's fill colour (folded into the cache key). Only paths and images inside a tile are rendered; nested text/shadings/patterns are skipped. Tests: a tiling pattern fills a path (`fill_pattern` + matrix), and an uncoloured pattern carries its paint colour. Reference-output submodules intentionally not bumped (left for regeneration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2b02d2c commit 500327d

8 files changed

Lines changed: 222 additions & 21 deletions

File tree

src/odr/internal/html/pdf_file.cpp

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,12 @@ std::string svg_path_d(const std::vector<pdf::Subpath> &subpaths,
129129
/// stroke carries width (CTM-scaled in user space), caps, joins, miter limit
130130
/// and the dash pattern. A zero stroke width renders as a thin hairline.
131131
/// `clip_id`, when non-empty, references a `<clipPath>` installed via
132-
/// `clip-path`. `gradient_id`, when non-empty, fills the path with that
133-
/// gradient (a shading pattern) instead of `fill_color`.
132+
/// `clip-path`. `fill_url_id`, when non-empty, fills the path with that paint
133+
/// server (a shading gradient or a tiling `<pattern>`) instead of `fill_color`.
134134
std::string svg_path_fragment(const pdf::PathElement &path,
135135
const util::math::Transform2D &to_box,
136136
const std::string &clip_id,
137-
const std::string &gradient_id) {
137+
const std::string &fill_url_id) {
138138
if ((!path.fill && !path.stroke) || path.subpaths.empty()) {
139139
return {};
140140
}
@@ -145,8 +145,8 @@ std::string svg_path_fragment(const pdf::PathElement &path,
145145
}
146146

147147
if (path.fill) {
148-
if (!gradient_id.empty()) {
149-
f << " fill=\"url(#" << gradient_id << ")\"";
148+
if (!fill_url_id.empty()) {
149+
f << " fill=\"url(#" << fill_url_id << ")\"";
150150
} else {
151151
f << " fill=\"" << device_color_to_css(path.fill_color) << '"';
152152
}
@@ -370,6 +370,79 @@ std::string svg_shading_fragment(const std::string &gradient_id,
370370
return std::move(f).str();
371371
}
372372

373+
/// Registers a page's tiling patterns (`/PatternType 1`) as SVG `<pattern>`
374+
/// defs. The pattern's content stream is run as a mini page (`extract_page`)
375+
/// into tile fragments laid out in pattern space; the `<pattern>` repeats them
376+
/// every `/XStep`/`/YStep`, and `patternTransform` (pattern space -> page box)
377+
/// places the lattice. An uncoloured pattern (`/PaintType 2`) ignores its
378+
/// content's own colours and paints in the path's fill colour, so the cache key
379+
/// folds that colour in. Ids are namespaced per page (`pat<page>_<n>`). Only
380+
/// paths and images inside the tile are rendered (nested text/shadings/patterns
381+
/// are skipped — rare). Returns "" for an unrepresentable pattern.
382+
class PatternRegistry {
383+
public:
384+
explicit PatternRegistry(const std::uint32_t page) : m_page{page} {}
385+
386+
std::string register_pattern(const pdf::Pattern &pattern,
387+
const util::math::Transform2D &m,
388+
const pdf::GraphicsState::Color &fill_color,
389+
const Logger &logger) {
390+
if (pattern.resources == nullptr || pattern.content.empty() ||
391+
pattern.x_step == 0 || pattern.y_step == 0) {
392+
return {};
393+
}
394+
const bool uncoloured = pattern.paint_type == 2;
395+
std::ostringstream sig;
396+
sig << static_cast<const void *>(&pattern) << ':' << m.a << ',' << m.b
397+
<< ',' << m.c << ',' << m.d << ',' << m.e << ',' << m.f;
398+
if (uncoloured) {
399+
sig << ':' << device_color_to_css(fill_color);
400+
}
401+
const auto [it, inserted] = m_id_by_signature.try_emplace(sig.str());
402+
if (!inserted) {
403+
return it->second;
404+
}
405+
it->second =
406+
"pat" + std::to_string(m_page) + "_" + std::to_string(++m_count);
407+
408+
// Tile content is laid out in pattern space (identity page transform); the
409+
// y-flip and placement live in `patternTransform`.
410+
const util::math::Transform2D identity;
411+
std::ostringstream tile;
412+
for (const pdf::PageElement &element :
413+
pdf::extract_page(pattern.content, *pattern.resources, logger)) {
414+
if (const auto *path = std::get_if<pdf::PathElement>(&element)) {
415+
pdf::PathElement painted = *path;
416+
if (uncoloured) {
417+
painted.fill_color = fill_color;
418+
painted.stroke_color = fill_color;
419+
}
420+
tile << svg_path_fragment(painted, identity, "", "");
421+
} else if (const auto *image = std::get_if<pdf::ImageElement>(&element)) {
422+
tile << svg_image_fragment(*image, identity, "");
423+
}
424+
}
425+
426+
m_defs << "<pattern id=\"" << it->second
427+
<< "\" patternUnits=\"userSpaceOnUse\" x=\""
428+
<< round2(pattern.bbox[0]) << "\" y=\"" << round2(pattern.bbox[1])
429+
<< "\" width=\"" << round2(std::abs(pattern.x_step))
430+
<< "\" height=\"" << round2(std::abs(pattern.y_step))
431+
<< "\" patternTransform=\"matrix(" << m.a << ',' << m.b << ',' << m.c
432+
<< ',' << m.d << ',' << round2(m.e) << ',' << round2(m.f) << ")\">"
433+
<< std::move(tile).str() << "</pattern>";
434+
return it->second;
435+
}
436+
437+
[[nodiscard]] std::string defs() const { return m_defs.str(); }
438+
439+
private:
440+
std::uint32_t m_page{};
441+
std::uint32_t m_count{0};
442+
std::unordered_map<std::string, std::string> m_id_by_signature;
443+
std::ostringstream m_defs;
444+
};
445+
373446
/// Deduplicates CSS declarations into atomic, single-property classes. PDF text
374447
/// emits one absolutely-positioned span per glyph run, and the same font sizes,
375448
/// offsets and spacings recur across the (potentially millions of) spans.
@@ -693,23 +766,28 @@ class HtmlServiceImpl final : public HtmlService {
693766

694767
ClipRegistry clips(static_cast<std::uint32_t>(pages_out.size()));
695768
GradientRegistry gradients(static_cast<std::uint32_t>(pages_out.size()));
769+
PatternRegistry patterns(static_cast<std::uint32_t>(pages_out.size()));
696770

697771
for (const pdf::PageElement &element :
698772
pdf::extract_page(stream, *page->resources, *m_logger)) {
699773
// A painted path: serialize its subpaths to an SVG `<path>` fragment in
700774
// the page viewBox (fill and/or stroke), under any active clip. A
701-
// shading-pattern fill is painted through a gradient instead of a
702-
// colour.
775+
// shading- or tiling-pattern fill is painted through a paint server
776+
// (gradient/`<pattern>`) instead of a colour.
703777
if (const auto *path = std::get_if<pdf::PathElement>(&element);
704778
path != nullptr) {
705779
const std::string clip_id = clips.register_clip(path->clip, to_box);
706-
std::string gradient_id;
780+
std::string fill_url_id;
707781
if (path->fill_shading != nullptr) {
708-
gradient_id = gradients.register_gradient(
782+
fill_url_id = gradients.register_gradient(
709783
*path->fill_shading, path->shading_transform * to_box);
784+
} else if (path->fill_pattern != nullptr) {
785+
fill_url_id = patterns.register_pattern(
786+
*path->fill_pattern, path->pattern_transform * to_box,
787+
path->fill_color, *m_logger);
710788
}
711789
std::string fragment =
712-
svg_path_fragment(*path, to_box, clip_id, gradient_id);
790+
svg_path_fragment(*path, to_box, clip_id, fill_url_id);
713791
if (!fragment.empty()) {
714792
page_out.items.push_back(PathOut{std::move(fragment)});
715793
}
@@ -959,8 +1037,9 @@ class HtmlServiceImpl final : public HtmlService {
9591037
}
9601038
}
9611039

962-
// Clip-path and gradient defs share the page's hidden `<svg><defs>`.
963-
page_out.clip_defs = clips.defs() + gradients.defs();
1040+
// Clip-path, gradient and pattern defs share the page's hidden
1041+
// `<svg><defs>`.
1042+
page_out.clip_defs = clips.defs() + gradients.defs() + patterns.defs();
9641043
}
9651044

9661045
// Post-pass: every page has been scanned, so the per-font used-scalar sets

src/odr/internal/pdf/AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,8 +584,15 @@ stage exists to avoid.
584584
parsed onto `Shading` but **not yet honoured** by the renderer (deferred): it
585585
always uses SVG's `pad` spread, so a non-extended shading is over-painted past
586586
its interval rather than masked to it (honouring it needs the fill clipped to
587-
the gradient band/annulus). Mesh/function shadings (types 1, 4–7) and tiling
588-
patterns (`/PatternType 1`) are still future stages.
587+
the gradient band/annulus).
588+
- **Tiling patterns** (`/PatternType 1`): the pattern's content stream is run as
589+
a mini page (`extract_page`) and emitted as an SVG `<pattern>` tile, repeated
590+
every `/XStep`/`/YStep`, with `patternTransform` (the pattern `/Matrix`)
591+
placing the lattice; a `/PatternType 1` fill references it as `fill="url(#…)"`.
592+
Coloured (`/PaintType 1`) cells carry their own colours; uncoloured
593+
(`/PaintType 2`) cells are painted in the current fill colour. Only paths and
594+
images inside a tile are rendered (nested text/shadings/patterns are skipped —
595+
rare).
589596
- **SVG residue** — where no 1:1 primitive exists; all at generation time, never
590597
rasterization: mesh/function shadings (types 1, 4–7) → tessellate into small
591598
flat polygons (pdf.js's approach); color spaces

src/odr/internal/pdf/pdf_document_element.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ struct Pattern final : Element {
177177
/// Shading pattern (`/PatternType 2`): the shading painted through the path,
178178
/// pre-resolved (its tint function sampled into stops). Null otherwise.
179179
std::shared_ptr<Shading> shading;
180+
181+
/// Tiling pattern (`/PatternType 1`, ISO 32000-1 8.7.3.1): a content-stream
182+
/// cell tiled across the filled region. `/PaintType` 1 (coloured) carries its
183+
/// own colours; `/PaintType` 2 (uncoloured) is painted entirely in the
184+
/// current fill colour at use time. The cell is `/BBox` in pattern space,
185+
/// repeated every `/XStep`/`/YStep`. Fields are zero/empty for a non-tiling
186+
/// pattern.
187+
std::int32_t paint_type{0}; ///< `/PaintType`: 1 coloured, 2 uncoloured
188+
std::array<double, 4> bbox{}; ///< `/BBox` `[x0 y0 x1 y1]`, pattern space
189+
double x_step{0}; ///< `/XStep`, pattern space
190+
double y_step{0}; ///< `/YStep`, pattern space
191+
Resources *resources{nullptr}; ///< the tile's own `/Resources`
192+
std::string content; ///< decoded tile content stream
180193
};
181194

182195
/// A non-owning view over a string of PDF character codes, splitting it into

src/odr/internal/pdf/pdf_document_parser.cpp

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ struct State {
5454
m_fonts[reference] = font;
5555
}
5656

57+
[[nodiscard]] Pattern *find_pattern(const ObjectReference &reference) const {
58+
const auto it = m_patterns.find(reference);
59+
return it != m_patterns.end() ? it->second : nullptr;
60+
}
61+
void cache_pattern(const ObjectReference &reference, Pattern *pattern) {
62+
m_patterns[reference] = pattern;
63+
}
64+
5765
private:
5866
DocumentParser *m_parser{};
5967
Document *m_document{};
@@ -74,6 +82,12 @@ struct State {
7482
/// re-inline the (base64) font program once per page — a multi-page document
7583
/// reusing one font would balloon to gigabytes.
7684
std::map<ObjectReference, Font *> m_fonts;
85+
86+
/// Memoized Pattern elements by reference, mirroring `m_x_objects`. A tiling
87+
/// pattern's own `/Resources` may name patterns (including, in a malformed
88+
/// file, itself); registering the element before its resources are parsed
89+
/// breaks the cycle and shares a pattern reused across pages.
90+
std::map<ObjectReference, Pattern *> m_patterns;
7791
};
7892

7993
/// Normalize /Rotate to {0, 90, 180, 270}: the spec requires a multiple of 90,
@@ -785,14 +799,25 @@ std::shared_ptr<Shading> parse_shading_resource(State &state,
785799
}
786800

787801
/// Parse a `/Pattern` entry. A shading pattern (`/PatternType 2`) resolves its
788-
/// `/Shading`; a tiling pattern (`/PatternType 1`) is recognized here and its
789-
/// content rendered in a later stage. `/Matrix` is taken either way.
802+
/// `/Shading`; a tiling pattern (`/PatternType 1`) is a stream whose content,
803+
/// `/Resources`, `/BBox`, `/XStep`/`/YStep` and `/PaintType` describe the tile.
804+
/// `/Matrix` is taken either way.
790805
Pattern *parse_pattern(State &state, const ObjectReference &reference,
791806
const Resources *resources) {
792807
DocumentParser &parser = state.parser();
793808
Document &document = state.document();
794809

810+
// Shared patterns are parsed once; a cycle through a tiling pattern's own
811+
// `/Resources` resolves to the existing element instead of recursing.
812+
if (Pattern *cached = state.find_pattern(reference); cached != nullptr) {
813+
return cached;
814+
}
815+
795816
auto *pattern = document.create_element<Pattern>();
817+
// Register before parsing `/Resources` so a cycle back here resolves to this
818+
// element rather than recursing forever.
819+
state.cache_pattern(reference, pattern);
820+
796821
IndirectObject object = parser.read_object(reference);
797822
if (!object.object.is_dictionary()) {
798823
return pattern;
@@ -814,6 +839,30 @@ Pattern *parse_pattern(State &state, const ObjectReference &reference,
814839
parse_shading_resource(state, dictionary.get("Shading"), resources);
815840
} else if (pattern_type == 1) {
816841
pattern->type = Pattern::Type::tiling;
842+
pattern->paint_type = static_cast<std::int32_t>(
843+
parser.resolve_object_copy(dictionary.get("PaintType"))
844+
.as_integer_opt()
845+
.value_or(1));
846+
pattern->x_step = parser.resolve_object_copy(dictionary.get("XStep"))
847+
.as_real_opt()
848+
.value_or(0);
849+
pattern->y_step = parser.resolve_object_copy(dictionary.get("YStep"))
850+
.as_real_opt()
851+
.value_or(0);
852+
if (dictionary.has_value("BBox")) {
853+
const Array box =
854+
parser.resolve_object_copy(dictionary["BBox"]).as_array();
855+
if (box.size() == 4) {
856+
pattern->bbox = {box[0].as_real(), box[1].as_real(), box[2].as_real(),
857+
box[3].as_real()};
858+
}
859+
}
860+
if (object.has_stream) {
861+
pattern->content = parser.read_decoded_stream(object);
862+
}
863+
if (dictionary.has_key("Resources")) {
864+
pattern->resources = parse_resources(state, dictionary["Resources"]);
865+
}
817866
}
818867
return pattern;
819868
}

src/odr/internal/pdf/pdf_page_element.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace odr::internal::pdf {
1111

1212
struct Font;
1313
struct Shading;
14+
struct Pattern;
1415

1516
/// One show-text operation laid out in user space. The transform places the
1617
/// text origin and orientation; the font size is kept separate so the renderer
@@ -90,6 +91,13 @@ struct PathElement {
9091
/// `Resources`, which outlives the element.
9192
const Shading *fill_shading{nullptr};
9293
util::math::Transform2D shading_transform;
94+
/// When the fill is a tiling pattern (`scn` naming a `/PatternType 1`
95+
/// pattern), the resolved pattern whose content cell tiles the path, with
96+
/// `pattern_transform` mapping pattern space to user space (the pattern
97+
/// `/Matrix`). An uncoloured pattern (`/PaintType 2`) is painted in
98+
/// `fill_color`. Null for a non-tiling fill. Owned by `Resources`.
99+
const Pattern *fill_pattern{nullptr};
100+
util::math::Transform2D pattern_transform;
93101
/// Stroke parameters. `line_width` and the dash lengths are in the path's
94102
/// user space (the CTM scale is already folded in, so they live in the same
95103
/// space as the geometry). A `line_width` of 0 means a device-thin line.

src/odr/internal/pdf/pdf_page_extractor.cpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,9 @@ void set_color_space(GraphicsState::Color &color, const std::string &name,
342342
/// colour space, interpret the components as a device colour by their count
343343
/// (ISO 32000-1 8.6.8). A trailing name operand selects a `/Pattern`: its name
344344
/// is recorded on `color.pattern` and resolved against `Resources::pattern` at
345-
/// paint time (a shading pattern then fills the path through its gradient).
345+
/// paint time (a shading pattern fills through its gradient). An uncoloured
346+
/// tiling pattern carries leading components — the colour in the pattern colour
347+
/// space's underlying space — which are still resolved into the fill colour.
346348
void set_color(GraphicsState::Color &color, const GraphicsOperator &op) {
347349
std::vector<double> components;
348350
std::string pattern_name;
@@ -354,9 +356,10 @@ void set_color(GraphicsState::Color &color, const GraphicsOperator &op) {
354356
}
355357
}
356358
color.pattern = pattern_name;
357-
if (!pattern_name.empty()) {
358-
// A pattern colour carries no device components to convert; the pattern is
359-
// resolved at paint time. Leave any underlying colour as-is.
359+
if (!pattern_name.empty() && components.empty()) {
360+
// A coloured pattern (or shading pattern) carries no components; the
361+
// pattern is resolved at paint time and any underlying colour is left
362+
// as-is.
360363
return;
361364
}
362365
if (color.def != nullptr) {
@@ -437,6 +440,9 @@ void paint_path(std::vector<PageElement> &out, const Resources &resources,
437440
pattern->shading != nullptr) {
438441
element.fill_shading = pattern->shading.get();
439442
element.shading_transform = pattern->matrix;
443+
} else if (pattern->type == Pattern::Type::tiling) {
444+
element.fill_pattern = pattern;
445+
element.pattern_transform = pattern->matrix;
440446
}
441447
}
442448
}

test/src/internal/pdf/pdf_page_extractor.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,45 @@ TEST(PdfPageExtractor, scn_unknown_pattern_has_no_shading) {
922922
EXPECT_EQ(std::get<PathElement>(page[0]).fill_shading, nullptr);
923923
}
924924

925+
// `scn` naming a `/PatternType 1` tiling pattern fills the path with the
926+
// pattern; `fill_pattern` is resolved and the pattern `/Matrix` carried.
927+
TEST(PdfPageExtractor, scn_tiling_pattern_fills_path) {
928+
Pattern pattern;
929+
pattern.type = Pattern::Type::tiling;
930+
pattern.matrix = Transform2D::translation(3, 4);
931+
Resources res;
932+
res.pattern["P1"] = &pattern;
933+
934+
const auto page =
935+
extract_page("/Pattern cs /P1 scn 0 0 10 10 re f", res, Logger::null());
936+
ASSERT_EQ(page.size(), 1);
937+
const PathElement &p = std::get<PathElement>(page[0]);
938+
ASSERT_NE(p.fill_pattern, nullptr);
939+
EXPECT_EQ(p.fill_pattern->type, Pattern::Type::tiling);
940+
EXPECT_EQ(p.fill_shading, nullptr);
941+
EXPECT_TRUE(p.fill);
942+
EXPECT_DOUBLE_EQ(p.pattern_transform.e, 3);
943+
EXPECT_DOUBLE_EQ(p.pattern_transform.f, 4);
944+
}
945+
946+
// An uncoloured tiling pattern (`/PaintType 2`) records the current fill colour
947+
// alongside the pattern, so the renderer can paint the cell in it.
948+
TEST(PdfPageExtractor, scn_uncoloured_tiling_pattern_carries_colour) {
949+
Pattern pattern;
950+
pattern.type = Pattern::Type::tiling;
951+
pattern.paint_type = 2;
952+
Resources res;
953+
res.pattern["P2"] = &pattern;
954+
955+
// The colour precedes the pattern selection in the Pattern colour space.
956+
const auto page = extract_page("/Pattern cs 1 0 0 /P2 scn 0 0 10 10 re f",
957+
res, Logger::null());
958+
ASSERT_EQ(page.size(), 1);
959+
const PathElement &p = std::get<PathElement>(page[0]);
960+
ASSERT_NE(p.fill_pattern, nullptr);
961+
EXPECT_EQ(p.fill_pattern->paint_type, 2);
962+
}
963+
925964
// The `sh` operator floods the current clip with a named `/Shading`, emitting a
926965
// `ShadingElement` placed by the CTM.
927966
TEST(PdfPageExtractor, sh_emits_shading_element) {

0 commit comments

Comments
 (0)