"""Function Plot → reportlab 矢量 Drawing(供 PDF 内嵌)。 消费 ``render.compute_geometry`` 的共享几何,产出 ``reportlab.graphics.shapes.Drawing``: 网格/坐标轴用 ``Line``、曲线用 ``PolyLine``、刻度数字与轴标签用 ``String``。 reportlab 原点在左下(y-up),与 SVG 的 y-down 相反,故对几何里的像素 y 统一翻转; 轴标签(ylabel)用 ``Group.rotate`` 旋转为竖向文本。中文字体复用内置 STSong-Light, guarded 注册避免与 pdf.py 重复注册。 """ from __future__ import annotations from reportlab.graphics.shapes import Drawing, Group, Line, PolyLine, String from reportlab.lib.colors import HexColor from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont from app.plot.model import FunctionPlot from app.plot.render import PlotGeometry, _fmt_num, _sx, _sy, compute_geometry from app.export.fonts import FONT as _FONT _GRID_COLOR = HexColor("#eaeef2") _AXIS_COLOR = HexColor("#57606a") _LABEL_COLOR = HexColor("#1f2328") _TICK_FONT_SIZE = 10 _LABEL_FONT_SIZE = 12 def _build_drawing(geo: PlotGeometry, palette=None) -> Drawing: """由共享几何构建矢量 Drawing(坐标翻转后仍沿用 SVG 的像素布局)。""" drawing = Drawing(geo.width, geo.height) grid_color = HexColor(palette['border']) if palette else _GRID_COLOR axis_color = HexColor(palette['muted']) if palette else _AXIS_COLOR label_color = HexColor(palette['text']) if palette else _LABEL_COLOR # SVG y-down → reportlab y-up:翻转像素 y def sx(x: float) -> float: return _sx(x, geo.xmin, geo.xmax) def sy(y: float) -> float: return geo.height - _sy(y, geo.ymin, geo.ymax) # 网格 if geo.grid: for x in geo.xticks: drawing.add( Line(sx(x), sy(geo.ymin), sx(x), sy(geo.ymax), strokeColor=grid_color, strokeWidth=0.5) ) for y in geo.yticks: drawing.add( Line(sx(geo.xmin), sy(y), sx(geo.xmax), sy(y), strokeColor=grid_color, strokeWidth=0.5) ) # 坐标轴(过原点画在原点,否则贴边,与 SVG 一致) drawing.add( Line(sx(geo.xmin), sy(geo.x_axis_y), sx(geo.xmax), sy(geo.x_axis_y), strokeColor=axis_color, strokeWidth=0.7) ) drawing.add( Line(sx(geo.y_axis_x), sy(geo.ymin), sx(geo.y_axis_x), sy(geo.ymax), strokeColor=axis_color, strokeWidth=0.7) ) # 刻度数字(x 轴下方、y 轴左侧) for x in geo.xticks: drawing.add( String( sx(x), sy(geo.x_axis_y) - 14, _fmt_num(x), fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="middle", ) ) for y in geo.yticks: drawing.add( String( sx(geo.y_axis_x) - 6, sy(y) - 3, _fmt_num(y), fontName=_FONT, fontSize=_TICK_FONT_SIZE, fillColor=axis_color, textAnchor="end", ) ) # 曲线(非有限点处已由几何断成多段) for segments, color in zip(geo.polylines, geo.colors): for seg in segments: flipped = [(px, geo.height - py) for px, py in seg] drawing.add(PolyLine(flipped, strokeColor=HexColor(color), strokeWidth=1.4)) # 轴标签 if geo.xlabel: drawing.add( String( geo.width / 2, 10, geo.xlabel, fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle", ) ) if geo.ylabel: # 竖向标签:Group.rotate(90) 在 y-up 坐标下等价于 SVG 的 rotate(-90)。 # 文本放在组内局部坐标 (0,0),先平移后旋转得到 T·R(先绕原点旋转、再平移到 # 目标位置),避免用绝对坐标定位又用相同坐标当旋转中心造成的重复变换, # 后者会把标签甩到画布之外(负 x 区域)。 label = Group() label.add( String( 0, 0, geo.ylabel, fontName=_FONT, fontSize=_LABEL_FONT_SIZE, fillColor=label_color, textAnchor="middle", ) ) label.translate(16, geo.height / 2) label.rotate(90) drawing.add(label) return drawing def render_drawing(plot: FunctionPlot, width: float | None = None, palette=None, unlimited=False, max_height=None) -> Drawing: """把已解析的 FunctionPlot 渲染为 reportlab Drawing(可直接追加到 platypus story)。 ``width`` 为目标输出宽度(点),用于把 640px 的几何缩放到页面内容宽;省略则按 原始尺寸输出。缩放只影响 PDF 渲染,不改动共享几何。 """ geo = compute_geometry(plot, unlimited=unlimited) if palette: from reportlab.lib.colors import HexColor as color bg = color(palette['surface']) if .2126*bg.red + .7152*bg.green + .0722*bg.blue < .5: colors = ['#79c0ff','#ff9b9b','#7ee787','#d2a8ff','#f2cc60','#ffa657'] geo.colors = [value if plot.expressions[i].color else colors[i % len(colors)] for i,value in enumerate(geo.colors)] drawing = _build_drawing(geo, palette) legend_height = ((len(plot.expressions)+1)//2)*24 drawing.height += legend_height for index, expression in enumerate(plot.expressions): drawing.add(String(24+(index%2)*310,geo.height+legend_height-18-(index//2)*24, expression.label or 'y = '+expression.expression,fontName=_FONT,fontSize=12,fillColor=HexColor(geo.colors[index]))) if width is not None and width > 0: drawing.renderScale = min(1.0, width / geo.width, max_height / drawing.height if max_height else 1.0) return drawing