@@ -79,6 +79,18 @@ std::string device_color_to_css(const pdf::GraphicsState::Color &color) {
7979 return std::move (s).str ();
8080}
8181
82+ // / Convert an sRGB triple in [0, 1] (a shading colour stop) to a CSS
83+ // / `rgb(...)`.
84+ std::string rgb_to_css (const std::array<double , 3 > &rgb) {
85+ const auto to255 = [](const double v) {
86+ return static_cast <int >(std::lround (std::clamp (v, 0.0 , 1.0 ) * 255.0 ));
87+ };
88+ std::ostringstream s;
89+ s << " rgb(" << to255 (rgb[0 ]) << ' ,' << to255 (rgb[1 ]) << ' ,' << to255 (rgb[2 ])
90+ << ' )' ;
91+ return std::move (s).str ();
92+ }
93+
8294// / Build an SVG `d` attribute from a path's subpaths, each point mapped through
8395// / `to_box` (PDF user space -> the page box, y-down). Lines become `L`, cubic
8496// / Béziers `C`, and an explicitly closed subpath ends with `Z`.
@@ -117,10 +129,12 @@ std::string svg_path_d(const std::vector<pdf::Subpath> &subpaths,
117129// / stroke carries width (CTM-scaled in user space), caps, joins, miter limit
118130// / and the dash pattern. A zero stroke width renders as a thin hairline.
119131// / `clip_id`, when non-empty, references a `<clipPath>` installed via
120- // / `clip-path`.
132+ // / `clip-path`. `gradient_id`, when non-empty, fills the path with that
133+ // / gradient (a shading pattern) instead of `fill_color`.
121134std::string svg_path_fragment (const pdf::PathElement &path,
122135 const util::math::Transform2D &to_box,
123- const std::string &clip_id) {
136+ const std::string &clip_id,
137+ const std::string &gradient_id) {
124138 if ((!path.fill && !path.stroke ) || path.subpaths .empty ()) {
125139 return {};
126140 }
@@ -131,7 +145,11 @@ std::string svg_path_fragment(const pdf::PathElement &path,
131145 }
132146
133147 if (path.fill ) {
134- f << " fill=\" " << device_color_to_css (path.fill_color ) << ' "' ;
148+ if (!gradient_id.empty ()) {
149+ f << " fill=\" url(#" << gradient_id << " )\" " ;
150+ } else {
151+ f << " fill=\" " << device_color_to_css (path.fill_color ) << ' "' ;
152+ }
135153 if (path.even_odd ) {
136154 f << " fill-rule=\" evenodd\" " ;
137155 }
@@ -253,6 +271,87 @@ class ClipRegistry {
253271 std::ostringstream m_defs;
254272};
255273
274+ // / Registers a page's shadings (axial/radial) as `<linearGradient>`/
275+ // / `<radialGradient>` defs, deduplicating by shading and placement. The
276+ // / shading's pre-sampled colour stops become `<stop>`s; `gradientTransform`
277+ // / (shading space -> page box) places the gradient in the page's user space, so
278+ // / referencing elements use `gradientUnits="userSpaceOnUse"`. PDF `/Extend` is
279+ // / approximated by SVG's default `pad` spread (the end stops extend outward).
280+ // / Ids are namespaced per page (`g<page>_<n>`).
281+ class GradientRegistry {
282+ public:
283+ explicit GradientRegistry (std::uint32_t page) : m_page{page} {}
284+
285+ // / The gradient id to reference via `fill="url(#id)"` for `shading` placed by
286+ // / `m` (shading space -> page box). Empty for an unrepresentable shading.
287+ std::string register_gradient (const pdf::Shading &shading,
288+ const util::math::Transform2D &m) {
289+ if ((shading.type != 2 && shading.type != 3 ) || shading.stops .empty ()) {
290+ return {};
291+ }
292+ std::ostringstream sig;
293+ sig << shading.type << ' :' << static_cast <const void *>(&shading) << ' :'
294+ << m.a << ' ,' << m.b << ' ,' << m.c << ' ,' << m.d << ' ,' << m.e << ' ,'
295+ << m.f ;
296+ const auto [it, inserted] = m_id_by_signature.try_emplace (sig.str ());
297+ if (!inserted) {
298+ return it->second ;
299+ }
300+ it->second = " g" + std::to_string (m_page) + " _" + std::to_string (++m_count);
301+ const std::string &id = it->second ;
302+
303+ const std::array<double , 6 > &c = shading.coords ;
304+ if (shading.type == 2 ) {
305+ m_defs << " <linearGradient id=\" " << id << " \" x1=\" " << c[0 ]
306+ << " \" y1=\" " << c[1 ] << " \" x2=\" " << c[2 ] << " \" y2=\" " << c[3 ]
307+ << ' "' ;
308+ } else {
309+ // Radial: the outer circle (x1,y1,r1) is SVG's (cx,cy,r); the inner
310+ // circle (x0,y0,r0) is the focal point and radius (fr is SVG2).
311+ m_defs << " <radialGradient id=\" " << id << " \" cx=\" " << c[3 ]
312+ << " \" cy=\" " << c[4 ] << " \" r=\" " << c[5 ] << " \" fx=\" " << c[0 ]
313+ << " \" fy=\" " << c[1 ] << " \" fr=\" " << c[2 ] << ' "' ;
314+ }
315+ m_defs << " gradientUnits=\" userSpaceOnUse\" gradientTransform=\" matrix("
316+ << m.a << ' ,' << m.b << ' ,' << m.c << ' ,' << m.d << ' ,'
317+ << round2 (m.e ) << ' ,' << round2 (m.f ) << " )\" >" ;
318+ for (const pdf::GradientStop &stop : shading.stops ) {
319+ m_defs << " <stop offset=\" " << round2 (stop.offset ) << " \" stop-color=\" "
320+ << rgb_to_css (stop.rgb ) << " \" />" ;
321+ }
322+ m_defs << (shading.type == 2 ? " </linearGradient>" : " </radialGradient>" );
323+ return id;
324+ }
325+
326+ [[nodiscard]] std::string defs () const { return m_defs.str (); }
327+
328+ private:
329+ std::uint32_t m_page;
330+ std::uint32_t m_count{0 };
331+ std::unordered_map<std::string, std::string> m_id_by_signature;
332+ std::ostringstream m_defs;
333+ };
334+
335+ // / Serialize an `sh` shading flood to an SVG `<rect>` covering the page box,
336+ // / filled with `gradient_id` and bounded by `clip_id` (the clip in force at
337+ // / `sh` time). Returns "" when the shading produced no gradient. The rect spans
338+ // / the whole page; the clip (and the gradient's own extent) bound the paint.
339+ std::string svg_shading_fragment (const std::string &gradient_id,
340+ const std::string &clip_id, double width,
341+ double height) {
342+ if (gradient_id.empty ()) {
343+ return {};
344+ }
345+ std::ostringstream f;
346+ f << " <rect x=\" 0\" y=\" 0\" width=\" " << round2 (width) << " \" height=\" "
347+ << round2 (height) << " \" fill=\" url(#" << gradient_id << " )\" " ;
348+ if (!clip_id.empty ()) {
349+ f << " clip-path=\" url(#" << clip_id << " )\" " ;
350+ }
351+ f << " />" ;
352+ return std::move (f).str ();
353+ }
354+
256355// / Deduplicates CSS declarations into atomic, single-property classes. PDF text
257356// / emits one absolutely-positioned span per glyph run, and the same font sizes,
258357// / offsets and spacings recur across the (potentially millions of) spans.
@@ -575,14 +674,41 @@ class HtmlServiceImpl final : public HtmlService {
575674 util::math::Transform2D::scaling_translation (1 , -1 , 0 , height);
576675
577676 ClipRegistry clips (static_cast <std::uint32_t >(pages_out.size ()));
677+ GradientRegistry gradients (static_cast <std::uint32_t >(pages_out.size ()));
578678
579679 for (const pdf::PageElement &element :
580680 pdf::extract_page (stream, *page->resources , *m_logger)) {
581681 // A painted path: serialize its subpaths to an SVG `<path>` fragment in
582- // the page viewBox (fill and/or stroke), under any active clip.
682+ // the page viewBox (fill and/or stroke), under any active clip. A
683+ // shading-pattern fill is painted through a gradient instead of a
684+ // colour.
583685 if (const auto *path = std::get_if<pdf::PathElement>(&element)) {
584686 const std::string clip_id = clips.register_clip (path->clip , to_box);
585- std::string fragment = svg_path_fragment (*path, to_box, clip_id);
687+ std::string gradient_id;
688+ if (path->fill_shading != nullptr ) {
689+ gradient_id = gradients.register_gradient (
690+ *path->fill_shading , path->shading_transform * to_box);
691+ }
692+ std::string fragment =
693+ svg_path_fragment (*path, to_box, clip_id, gradient_id);
694+ if (!fragment.empty ()) {
695+ page_out.items .push_back (PathOut{std::move (fragment)});
696+ }
697+ continue ;
698+ }
699+
700+ // An `sh` shading flood: a `<rect>` over the page box filled with the
701+ // shading's gradient, bounded by the clip in force at `sh` time.
702+ if (const auto *shading = std::get_if<pdf::ShadingElement>(&element)) {
703+ if (shading->shading == nullptr ) {
704+ continue ;
705+ }
706+ const std::string clip_id =
707+ clips.register_clip (shading->clip , to_box);
708+ const std::string gradient_id = gradients.register_gradient (
709+ *shading->shading , shading->transform * to_box);
710+ std::string fragment =
711+ svg_shading_fragment (gradient_id, clip_id, width, height);
586712 if (!fragment.empty ()) {
587713 page_out.items .push_back (PathOut{std::move (fragment)});
588714 }
@@ -788,7 +914,8 @@ class HtmlServiceImpl final : public HtmlService {
788914 }
789915 }
790916
791- page_out.clip_defs = clips.defs ();
917+ // Clip-path and gradient defs share the page's hidden `<svg><defs>`.
918+ page_out.clip_defs = clips.defs () + gradients.defs ();
792919 }
793920
794921 // Post-pass: every page has been scanned, so the per-font used-scalar sets
@@ -934,10 +1061,11 @@ class HtmlServiceImpl final : public HtmlService {
9341061 for (const PageOut &page : pages_out) {
9351062 out.write_element_begin (" div" ,
9361063 HtmlElementOptions ().set_class (page.classes ));
937- // Clip-path defs for this page, in a hidden zero-size `<svg>`. They are
938- // referenced by id from the page's path fragments; `clipPathUnits`
939- // defaults to `userSpaceOnUse`, so the geometry is read in the user space
940- // of the referencing element (the page viewBox), not this `<svg>`.
1064+ // Clip-path and gradient defs for this page, in a hidden zero-size
1065+ // `<svg>`. They are referenced by id from the page's fragments;
1066+ // `clipPathUnits`/`gradientUnits` are `userSpaceOnUse`, so the geometry
1067+ // is read in the user space of the referencing element (the page
1068+ // viewBox), not this `<svg>`.
9411069 if (!page.clip_defs .empty ()) {
9421070 out.write_raw (
9431071 " <svg width=\" 0\" height=\" 0\" style=\" position:absolute\" >"
0 commit comments