@@ -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`.
134134std::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
0 commit comments