modm_data.pdf2html.figure

  1# Copyright 2022, Niklas Hauser
  2# SPDX-License-Identifier: MPL-2.0
  3
  4import math
  5import ctypes
  6from lxml import etree
  7from ..utils import Rectangle, Point
  8from ..pdf import Path, Image
  9
 10
 11# The thinnest stroke width, about one pixel when rendered at 3x scale
 12_HAIRLINE = 0.3
 13
 14
 15def _n(value: float) -> str:
 16    return f"{value:.2f}".rstrip("0").rstrip(".")
 17
 18
 19def _color(node, name: str, rgba: int):
 20    node.set(name, f"#{rgba >> 8:06x}")
 21    if (alpha := rgba & 0xFF) < 0xFF:
 22        node.set(f"{name}-opacity", _n(alpha / 0xFF))
 23
 24
 25class Figure:
 26    def __init__(self, page, bbox: Rectangle, cbbox: Rectangle = None, paths: list = None):
 27        self._page = page
 28        self.bbox = bbox
 29        self.cbbox = cbbox
 30        self._type = "figure"
 31        self._paths = paths or []
 32
 33    def _xy(self, point) -> str:
 34        # PDF has its origin in the bottom left corner, SVG in the top left
 35        return f"{_n(point.x - self.bbox.left)} {_n(self.bbox.top - point.y)}"
 36
 37    def _svg_path(self, path: Path):
 38        data, curve = [], []
 39        for point in path.points:
 40            if point.type == Path.Type.BEZIER:
 41                # Cubic curves consist of two control points and the end point
 42                curve.append(self._xy(point))
 43                if len(curve) == 3:
 44                    data.append("C" + " ".join(curve))
 45                    curve = []
 46            else:
 47                data.append(("M" if point.type == Path.Type.MOVE else "L") + self._xy(point))
 48        node = etree.Element("path", d="".join(data))
 49
 50        fill_mode, stroked = path.draw_mode
 51        if fill_mode:
 52            _color(node, "fill", path.fill)
 53            if fill_mode == 1:
 54                node.set("fill-rule", "evenodd")
 55        else:
 56            node.set("fill", "none")
 57        if stroked:
 58            _color(node, "stroke", path.stroke)
 59            # The stroke width is not part of the path and must be scaled
 60            m = path.matrix.get()
 61            scale = math.sqrt(abs(m[0] * m[3] - m[1] * m[2]))
 62            # PDF viewers draw strokes at least one device pixel wide, so that
 63            # hairlines stay visible, which SVG renderers do not do
 64            node.set("stroke-width", _n(max(path.width * scale, _HAIRLINE)))
 65            if path.dashes:
 66                node.set("stroke-dasharray", " ".join(_n(d * scale) for d in path.dashes))
 67            if path.cap != Path.Cap.BUTT:
 68                node.set("stroke-linecap", "round" if path.cap == Path.Cap.ROUND else "square")
 69            if path.join != Path.Join.MITER:
 70                node.set("stroke-linejoin", path.join.name.lower())
 71        return node
 72
 73    def _svg_texts(self):
 74        for line in self._page.charlines_in_area(self.bbox):
 75            horizontal = not line.rotation
 76            # Split the line into runs of the same font, size and baseline, and
 77            # at gaps, since labels in figures are unrelated to each other
 78            runs, last = [], None
 79            for char in line.chars:
 80                if horizontal:
 81                    baseline, start, stop = char.origin.y, char.bbox.left, char.bbox.right
 82                elif line.rotation == 90:
 83                    baseline, start, stop = char.origin.x, char.bbox.bottom, char.bbox.top
 84                else:
 85                    baseline, start, stop = char.origin.x, -char.bbox.top, -char.bbox.bottom
 86                key = (char.font, round(char.height, 1), round(baseline, 1))
 87                if char.unicode in {0x20, 0xA, 0xD}:
 88                    # Whitespace has no reliable position, it continues the run
 89                    if runs:
 90                        runs[-1][1].append(char)
 91                    continue
 92                if not runs or runs[-1][0] != key or start - last > 0.5 * line.height:
 93                    runs.append((key, []))
 94                runs[-1][1].append(char)
 95                last = stop
 96
 97            for (font, size, _), chars in runs:
 98                while chars and not chars[-1].char.strip():
 99                    chars = chars[:-1]
100                if not chars or not size:
101                    continue
102                node = etree.Element("text")
103                node.text = "".join(c.char for c in chars)
104                node.set("font-size", _n(size))
105                if "Bold" in font:
106                    node.set("font-weight", "bold")
107                if "Italic" in font or "Oblique" in font:
108                    node.set("font-style", "italic")
109                if chars[0].fill != 0xFF:
110                    _color(node, "fill", chars[0].fill)
111                xy = self._xy(chars[0].origin)
112                if horizontal:
113                    length = chars[-1].bbox.right - chars[0].bbox.left
114                    node.set("x", xy.split()[0])
115                    node.set("y", xy.split()[1])
116                else:
117                    length = chars[-1].bbox.top - chars[0].bbox.bottom
118                    angle = -90 if line.rotation == 90 else 90
119                    node.set("transform", f"translate({xy}) rotate({angle})")
120                # Keeps the layout intact when rendered with another font
121                if len(chars) > 1 and length > 0:
122                    node.set("textLength", _n(abs(length)))
123                yield node
124
125    def _in_bbox(self, objects: list) -> list:
126        box = self.bbox
127        return [
128            o
129            for o in objects
130            if o.bbox.left < box.right
131            and box.left < o.bbox.right
132            and o.bbox.bottom < box.top
133            and box.bottom < o.bbox.top
134        ]
135
136    @property
137    def images(self) -> list[Image]:
138        """The bitmap images of the figure."""
139        return self._in_bbox(self._page.images)
140
141    def _svg_image(self, image: Image, href: str):
142        node = etree.Element("image", href=href, preserveAspectRatio="none")
143        m = image.matrix.get()
144        # Images in forms have a matrix relative to the form, then we only know their bounds
145        xs = [m[4], m[4] + m[0], m[4] + m[2], m[4] + m[0] + m[2]]
146        ys = [m[5], m[5] + m[1], m[5] + m[3], m[5] + m[1] + m[3]]
147        matching = Rectangle(min(xs), min(ys), max(xs), max(ys)).isclose(image.bbox, atol=0.5)
148        if self._page.rotation or not matching:
149            x, y = self._xy(Point(image.bbox.left, image.bbox.top)).split()
150            node.set("x", x)
151            node.set("y", y)
152            node.set("width", _n(image.bbox.width))
153            node.set("height", _n(image.bbox.height))
154        else:
155            # The image matrix maps the unit square to the page and may flip or
156            # rotate the image, its top row is at y=1, while SVG starts at y=0
157            a, b, c, d, e, f = m
158            node.set("width", "1")
159            node.set("height", "1")
160            matrix = (a, -b, -c, d, c + e - self.bbox.left, self.bbox.top - d - f)
161            node.set("transform", f"matrix({' '.join(f'{v:.4f}'.rstrip('0').rstrip('.') for v in matrix)})")
162        return node
163
164    def as_svg(self, image_hrefs: list[str] = None) -> etree._Element | None:
165        """
166        :param image_hrefs: The file names of the saved `images`, which are
167                            then referenced by the SVG at their position.
168        :return: The vector graphics and text of the figure as SVG, or `None`
169                 if the figure has neither, for example, for bitmap images.
170        """
171        paths = [p for p in self._in_bbox(self._page.paths) if p.count > 1 and any(p.draw_mode)]
172        texts = list(self._svg_texts())
173        if not paths and not texts:
174            return None
175
176        # Paths and images are drawn in the order of the page, since a filled
177        # path may be the background of an image or cover parts of it
178        order = {ctypes.cast(o.raw, ctypes.c_void_p).value: i for i, o in enumerate(self._page.get_objects())}
179        graphics = [(p, self._svg_path(p)) for p in paths]
180        graphics += [(i, self._svg_image(i, h)) for i, h in zip(self.images, image_hrefs or [])]
181        graphics.sort(key=lambda g: order.get(ctypes.cast(g[0].raw, ctypes.c_void_p).value, -1))
182        nodes = [node for _, node in graphics] + texts
183
184        svg = etree.Element("svg", xmlns="http://www.w3.org/2000/svg")
185        svg.set("viewBox", f"0 0 {_n(self.bbox.width)} {_n(self.bbox.height)}")
186        svg.set("width", _n(self.bbox.width))
187        svg.set("height", _n(self.bbox.height))
188        svg.set("font-family", "Arial, Helvetica, sans-serif")
189        svg.extend(nodes)
190        return svg
191
192    def __repr__(self) -> str:
193        return f"Figure({int(self.bbox.width)}x{int(self.bbox.height)})"
class Figure:
 26class Figure:
 27    def __init__(self, page, bbox: Rectangle, cbbox: Rectangle = None, paths: list = None):
 28        self._page = page
 29        self.bbox = bbox
 30        self.cbbox = cbbox
 31        self._type = "figure"
 32        self._paths = paths or []
 33
 34    def _xy(self, point) -> str:
 35        # PDF has its origin in the bottom left corner, SVG in the top left
 36        return f"{_n(point.x - self.bbox.left)} {_n(self.bbox.top - point.y)}"
 37
 38    def _svg_path(self, path: Path):
 39        data, curve = [], []
 40        for point in path.points:
 41            if point.type == Path.Type.BEZIER:
 42                # Cubic curves consist of two control points and the end point
 43                curve.append(self._xy(point))
 44                if len(curve) == 3:
 45                    data.append("C" + " ".join(curve))
 46                    curve = []
 47            else:
 48                data.append(("M" if point.type == Path.Type.MOVE else "L") + self._xy(point))
 49        node = etree.Element("path", d="".join(data))
 50
 51        fill_mode, stroked = path.draw_mode
 52        if fill_mode:
 53            _color(node, "fill", path.fill)
 54            if fill_mode == 1:
 55                node.set("fill-rule", "evenodd")
 56        else:
 57            node.set("fill", "none")
 58        if stroked:
 59            _color(node, "stroke", path.stroke)
 60            # The stroke width is not part of the path and must be scaled
 61            m = path.matrix.get()
 62            scale = math.sqrt(abs(m[0] * m[3] - m[1] * m[2]))
 63            # PDF viewers draw strokes at least one device pixel wide, so that
 64            # hairlines stay visible, which SVG renderers do not do
 65            node.set("stroke-width", _n(max(path.width * scale, _HAIRLINE)))
 66            if path.dashes:
 67                node.set("stroke-dasharray", " ".join(_n(d * scale) for d in path.dashes))
 68            if path.cap != Path.Cap.BUTT:
 69                node.set("stroke-linecap", "round" if path.cap == Path.Cap.ROUND else "square")
 70            if path.join != Path.Join.MITER:
 71                node.set("stroke-linejoin", path.join.name.lower())
 72        return node
 73
 74    def _svg_texts(self):
 75        for line in self._page.charlines_in_area(self.bbox):
 76            horizontal = not line.rotation
 77            # Split the line into runs of the same font, size and baseline, and
 78            # at gaps, since labels in figures are unrelated to each other
 79            runs, last = [], None
 80            for char in line.chars:
 81                if horizontal:
 82                    baseline, start, stop = char.origin.y, char.bbox.left, char.bbox.right
 83                elif line.rotation == 90:
 84                    baseline, start, stop = char.origin.x, char.bbox.bottom, char.bbox.top
 85                else:
 86                    baseline, start, stop = char.origin.x, -char.bbox.top, -char.bbox.bottom
 87                key = (char.font, round(char.height, 1), round(baseline, 1))
 88                if char.unicode in {0x20, 0xA, 0xD}:
 89                    # Whitespace has no reliable position, it continues the run
 90                    if runs:
 91                        runs[-1][1].append(char)
 92                    continue
 93                if not runs or runs[-1][0] != key or start - last > 0.5 * line.height:
 94                    runs.append((key, []))
 95                runs[-1][1].append(char)
 96                last = stop
 97
 98            for (font, size, _), chars in runs:
 99                while chars and not chars[-1].char.strip():
100                    chars = chars[:-1]
101                if not chars or not size:
102                    continue
103                node = etree.Element("text")
104                node.text = "".join(c.char for c in chars)
105                node.set("font-size", _n(size))
106                if "Bold" in font:
107                    node.set("font-weight", "bold")
108                if "Italic" in font or "Oblique" in font:
109                    node.set("font-style", "italic")
110                if chars[0].fill != 0xFF:
111                    _color(node, "fill", chars[0].fill)
112                xy = self._xy(chars[0].origin)
113                if horizontal:
114                    length = chars[-1].bbox.right - chars[0].bbox.left
115                    node.set("x", xy.split()[0])
116                    node.set("y", xy.split()[1])
117                else:
118                    length = chars[-1].bbox.top - chars[0].bbox.bottom
119                    angle = -90 if line.rotation == 90 else 90
120                    node.set("transform", f"translate({xy}) rotate({angle})")
121                # Keeps the layout intact when rendered with another font
122                if len(chars) > 1 and length > 0:
123                    node.set("textLength", _n(abs(length)))
124                yield node
125
126    def _in_bbox(self, objects: list) -> list:
127        box = self.bbox
128        return [
129            o
130            for o in objects
131            if o.bbox.left < box.right
132            and box.left < o.bbox.right
133            and o.bbox.bottom < box.top
134            and box.bottom < o.bbox.top
135        ]
136
137    @property
138    def images(self) -> list[Image]:
139        """The bitmap images of the figure."""
140        return self._in_bbox(self._page.images)
141
142    def _svg_image(self, image: Image, href: str):
143        node = etree.Element("image", href=href, preserveAspectRatio="none")
144        m = image.matrix.get()
145        # Images in forms have a matrix relative to the form, then we only know their bounds
146        xs = [m[4], m[4] + m[0], m[4] + m[2], m[4] + m[0] + m[2]]
147        ys = [m[5], m[5] + m[1], m[5] + m[3], m[5] + m[1] + m[3]]
148        matching = Rectangle(min(xs), min(ys), max(xs), max(ys)).isclose(image.bbox, atol=0.5)
149        if self._page.rotation or not matching:
150            x, y = self._xy(Point(image.bbox.left, image.bbox.top)).split()
151            node.set("x", x)
152            node.set("y", y)
153            node.set("width", _n(image.bbox.width))
154            node.set("height", _n(image.bbox.height))
155        else:
156            # The image matrix maps the unit square to the page and may flip or
157            # rotate the image, its top row is at y=1, while SVG starts at y=0
158            a, b, c, d, e, f = m
159            node.set("width", "1")
160            node.set("height", "1")
161            matrix = (a, -b, -c, d, c + e - self.bbox.left, self.bbox.top - d - f)
162            node.set("transform", f"matrix({' '.join(f'{v:.4f}'.rstrip('0').rstrip('.') for v in matrix)})")
163        return node
164
165    def as_svg(self, image_hrefs: list[str] = None) -> etree._Element | None:
166        """
167        :param image_hrefs: The file names of the saved `images`, which are
168                            then referenced by the SVG at their position.
169        :return: The vector graphics and text of the figure as SVG, or `None`
170                 if the figure has neither, for example, for bitmap images.
171        """
172        paths = [p for p in self._in_bbox(self._page.paths) if p.count > 1 and any(p.draw_mode)]
173        texts = list(self._svg_texts())
174        if not paths and not texts:
175            return None
176
177        # Paths and images are drawn in the order of the page, since a filled
178        # path may be the background of an image or cover parts of it
179        order = {ctypes.cast(o.raw, ctypes.c_void_p).value: i for i, o in enumerate(self._page.get_objects())}
180        graphics = [(p, self._svg_path(p)) for p in paths]
181        graphics += [(i, self._svg_image(i, h)) for i, h in zip(self.images, image_hrefs or [])]
182        graphics.sort(key=lambda g: order.get(ctypes.cast(g[0].raw, ctypes.c_void_p).value, -1))
183        nodes = [node for _, node in graphics] + texts
184
185        svg = etree.Element("svg", xmlns="http://www.w3.org/2000/svg")
186        svg.set("viewBox", f"0 0 {_n(self.bbox.width)} {_n(self.bbox.height)}")
187        svg.set("width", _n(self.bbox.width))
188        svg.set("height", _n(self.bbox.height))
189        svg.set("font-family", "Arial, Helvetica, sans-serif")
190        svg.extend(nodes)
191        return svg
192
193    def __repr__(self) -> str:
194        return f"Figure({int(self.bbox.width)}x{int(self.bbox.height)})"
Figure( page, bbox: modm_data.utils.Rectangle, cbbox: modm_data.utils.Rectangle = None, paths: list = None)
27    def __init__(self, page, bbox: Rectangle, cbbox: Rectangle = None, paths: list = None):
28        self._page = page
29        self.bbox = bbox
30        self.cbbox = cbbox
31        self._type = "figure"
32        self._paths = paths or []
bbox
cbbox
images: list[modm_data.pdf.Image]
137    @property
138    def images(self) -> list[Image]:
139        """The bitmap images of the figure."""
140        return self._in_bbox(self._page.images)

The bitmap images of the figure.

def as_svg(self, image_hrefs: list[str] = None) -> lxml.etree._Element | None:
165    def as_svg(self, image_hrefs: list[str] = None) -> etree._Element | None:
166        """
167        :param image_hrefs: The file names of the saved `images`, which are
168                            then referenced by the SVG at their position.
169        :return: The vector graphics and text of the figure as SVG, or `None`
170                 if the figure has neither, for example, for bitmap images.
171        """
172        paths = [p for p in self._in_bbox(self._page.paths) if p.count > 1 and any(p.draw_mode)]
173        texts = list(self._svg_texts())
174        if not paths and not texts:
175            return None
176
177        # Paths and images are drawn in the order of the page, since a filled
178        # path may be the background of an image or cover parts of it
179        order = {ctypes.cast(o.raw, ctypes.c_void_p).value: i for i, o in enumerate(self._page.get_objects())}
180        graphics = [(p, self._svg_path(p)) for p in paths]
181        graphics += [(i, self._svg_image(i, h)) for i, h in zip(self.images, image_hrefs or [])]
182        graphics.sort(key=lambda g: order.get(ctypes.cast(g[0].raw, ctypes.c_void_p).value, -1))
183        nodes = [node for _, node in graphics] + texts
184
185        svg = etree.Element("svg", xmlns="http://www.w3.org/2000/svg")
186        svg.set("viewBox", f"0 0 {_n(self.bbox.width)} {_n(self.bbox.height)}")
187        svg.set("width", _n(self.bbox.width))
188        svg.set("height", _n(self.bbox.height))
189        svg.set("font-family", "Arial, Helvetica, sans-serif")
190        svg.extend(nodes)
191        return svg
Parameters
  • image_hrefs: The file names of the saved images, which are then referenced by the SVG at their position.
Returns

The vector graphics and text of the figure as SVG, or None if the figure has neither, for example, for bitmap images.