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)
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
Noneif the figure has neither, for example, for bitmap images.