Skip to main content

nagisa_render/
canvas.rs

1//! 自由画布 —— 在 `Document` 文档流之外,直接往一张 `tiny_skia` 画布上画形状(圆角矩形 / 渐变 /
2//! 线 / 圆 / 弧 / 多边形 / 雷达图)并合成文字盒,供数据卡片这类「图形为主、文字为辅」的出图。
3//!
4//! 文档引擎([`render_document`](crate::render_document))擅长「一大段排版」;卡片(如游戏面板、
5//! 战报、雷达图属性卡)需要精确摆放形状与短文本,文档流不趁手。本模块给一个像素级画布:
6//!
7//! - **形状**走 tiny-skia 抗锯齿填充 / 描边(与 [`crate::paint`] 同后端,观感一致)。
8//! - **文字**复用整条版式管线([`Doc`] → layout → paint):把一段样式化段落渲成透明底小图再合成,
9//!   于是 CJK 整形 / 字体回退 / 抗锯齿全部白拿,不另造字体轮子。
10//!
11//! 坐标与尺寸都是**逻辑像素**,内部统一乘 `scale` 换物理像素(与 [`RenderOptions::scale`] 一致)。
12//!
13//! ```ignore
14//! use nagisa_render::{Canvas, Color, RenderOptions, Align};
15//! let opts = RenderOptions::default();
16//! let mut c = Canvas::new(520.0, 300.0, 2.0)?;
17//! c.rect(0.0, 0.0, 520.0, 300.0, 24.0, Color::rgb(0x16, 0x1b, 0x22)); // 卡底
18//! c.radar(120.0, 150.0, 80.0, &[0.8, 0.6, 0.9, 0.4, 0.7], &Default::default());
19//! c.text(220.0, 24.0, 280.0, &opts, |p| { p.styled("疾风", |s| { s.weight(700).size(1.4); }); })?;
20//! let png = c.encode(nagisa_render::OutputFormat::Png)?;
21//! ```
22
23// 形状绘制 API 以坐标 / 尺寸 / 颜色为参,天然多参;不强拆成 struct(调用处更啰嗦)。
24#![allow(clippy::too_many_arguments)]
25
26use image::RgbaImage;
27use tiny_skia::{
28    FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, PixmapPaint, Point, PremultipliedColorU8, Rect,
29    Shader, SpreadMode, Stroke, Transform,
30};
31
32use crate::build::Doc;
33use crate::error::{Error, Result};
34use crate::model::Color;
35use crate::theme::{Insets, OutputFormat, RenderOptions};
36
37/// 雷达图样式。`values` 为各轴 0..1 归一值;轴数 = 顶点数。
38#[derive(Clone, Debug)]
39pub struct Radar {
40    /// 数据多边形填充色(通常带透明度)。
41    pub fill: Color,
42    /// 数据多边形描边色。
43    pub stroke: Color,
44    /// 数据多边形描边宽(逻辑像素)。
45    pub stroke_w: f32,
46    /// 网格(同心多边形 + 轴辐)色。
47    pub grid: Color,
48    /// 网格线宽(逻辑像素)。
49    pub grid_w: f32,
50    /// 同心网格圈数(≥1)。
51    pub rings: u32,
52    /// 顶点小圆点:`(半径, 色)`;`None` = 不画。
53    pub vertex_dot: Option<(f32, Color)>,
54    /// 第一个轴的角度(度,0 = 右、-90 = 正上;默认正上)。
55    pub start_deg: f32,
56}
57
58impl Default for Radar {
59    fn default() -> Self {
60        Self {
61            fill: Color::rgba(0x4c, 0x63, 0xb6, 0x66),
62            stroke: Color::rgb(0x4c, 0x63, 0xb6),
63            stroke_w: 2.0,
64            grid: Color::rgba(0x8b, 0x94, 0x9e, 0x55),
65            grid_w: 1.0,
66            rings: 4,
67            vertex_dot: Some((3.0, Color::rgb(0x4c, 0x63, 0xb6))),
68            start_deg: -90.0,
69        }
70    }
71}
72
73/// 像素级自由画布(内部 `tiny_skia::Pixmap`,逻辑坐标 × `scale`)。
74pub struct Canvas {
75    pix: Pixmap,
76    scale: f32,
77}
78
79impl Canvas {
80    /// 新建透明底画布:逻辑 `w`×`h`,物理尺寸 = 逻辑 × `scale`。
81    pub fn new(w: f32, h: f32, scale: f32) -> Result<Canvas> {
82        let scale = if scale.is_finite() { scale.clamp(0.25, 8.0) } else { 2.0 };
83        let pw = (w * scale).round().max(1.0) as u32;
84        let ph = (h * scale).round().max(1.0) as u32;
85        let pix = Pixmap::new(pw, ph).ok_or_else(|| Error::Layout("画布尺寸非法(过大或为 0)".into()))?;
86        Ok(Canvas { pix, scale })
87    }
88
89    /// 超采样系数。
90    pub fn scale(&self) -> f32 {
91        self.scale
92    }
93    /// 物理宽(像素)。
94    pub fn width_px(&self) -> u32 {
95        self.pix.width()
96    }
97    /// 物理高(像素)。
98    pub fn height_px(&self) -> u32 {
99        self.pix.height()
100    }
101
102    /// 逻辑值 → 物理值。
103    fn s(&self, v: f32) -> f32 {
104        v * self.scale
105    }
106
107    /// 整张填充某色(铺底)。
108    pub fn fill(&mut self, color: Color) {
109        self.pix.fill(skia(color));
110    }
111
112    /// 实心(可圆角)矩形。
113    pub fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, color: Color) {
114        if w <= 0.0 || h <= 0.0 || color.a == 0 {
115            return;
116        }
117        let mut paint = Paint::default();
118        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
119        paint.anti_alias = true;
120        self.fill_rrect(x, y, w, h, radius, &paint);
121    }
122
123    /// 描边(可圆角)矩形。线宽沿路径居中。
124    pub fn stroke_rect(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, line_w: f32, color: Color) {
125        if w <= 0.0 || h <= 0.0 || line_w <= 0.0 || color.a == 0 {
126            return;
127        }
128        let Some(path) = self.rrect_path(x, y, w, h, radius) else { return };
129        let mut paint = Paint::default();
130        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
131        paint.anti_alias = true;
132        let stroke = Stroke { width: self.s(line_w), ..Stroke::default() };
133        self.pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
134    }
135
136    /// 竖直线性渐变的(可圆角)矩形:顶 `top` → 底 `bottom`。
137    pub fn v_gradient(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, top: Color, bottom: Color) {
138        if w <= 0.0 || h <= 0.0 {
139            return;
140        }
141        let (px, py, pw, ph) = (self.s(x), self.s(y), self.s(w), self.s(h));
142        let shader = LinearGradient::new(
143            Point::from_xy(px, py),
144            Point::from_xy(px, py + ph),
145            vec![GradientStop::new(0.0, skia(top)), GradientStop::new(1.0, skia(bottom))],
146            SpreadMode::Pad,
147            Transform::identity(),
148        );
149        let paint = Paint {
150            shader: shader.unwrap_or_else(|| Shader::SolidColor(skia(top))),
151            anti_alias: true,
152            ..Default::default()
153        };
154        // 路径用物理坐标手搓(已乘 scale),故走 raw 版本。
155        if let Some(path) = rrect_path_px(px, py, pw, ph, self.s(radius)) {
156            self.pix.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
157        }
158    }
159
160    /// 直线段(圆头)。
161    pub fn line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, line_w: f32, color: Color) {
162        if line_w <= 0.0 || color.a == 0 {
163            return;
164        }
165        let mut pb = PathBuilder::new();
166        pb.move_to(self.s(x0), self.s(y0));
167        pb.line_to(self.s(x1), self.s(y1));
168        let Some(path) = pb.finish() else { return };
169        let mut paint = Paint::default();
170        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
171        paint.anti_alias = true;
172        let stroke = Stroke { width: self.s(line_w), line_cap: tiny_skia::LineCap::Round, ..Stroke::default() };
173        self.pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
174    }
175
176    /// 实心圆。
177    pub fn disc(&mut self, cx: f32, cy: f32, r: f32, color: Color) {
178        if r <= 0.0 || color.a == 0 {
179            return;
180        }
181        let Some(path) = oval_path_px(self.s(cx), self.s(cy), self.s(r)) else { return };
182        let mut paint = Paint::default();
183        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
184        paint.anti_alias = true;
185        self.pix.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
186    }
187
188    /// 描边圆环。
189    pub fn ring(&mut self, cx: f32, cy: f32, r: f32, line_w: f32, color: Color) {
190        if r <= 0.0 || line_w <= 0.0 || color.a == 0 {
191            return;
192        }
193        let Some(path) = oval_path_px(self.s(cx), self.s(cy), self.s(r)) else { return };
194        let mut paint = Paint::default();
195        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
196        paint.anti_alias = true;
197        let stroke = Stroke { width: self.s(line_w), ..Stroke::default() };
198        self.pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
199    }
200
201    /// 圆弧(圆头描边):从 `start_deg` 起、扫过 `sweep_deg`(度,0=右、顺时针为正)。用折线逼近,
202    /// 适合做环形进度 / 仪表。
203    pub fn arc(&mut self, cx: f32, cy: f32, r: f32, start_deg: f32, sweep_deg: f32, line_w: f32, color: Color) {
204        if r <= 0.0 || line_w <= 0.0 || color.a == 0 || sweep_deg == 0.0 {
205            return;
206        }
207        let (cx, cy, r) = (self.s(cx), self.s(cy), self.s(r));
208        let steps = ((sweep_deg.abs() / 4.0).ceil() as usize).max(2);
209        let mut pb = PathBuilder::new();
210        for i in 0..=steps {
211            let t = start_deg + sweep_deg * (i as f32 / steps as f32);
212            let (x, y) = (cx + r * t.to_radians().cos(), cy + r * t.to_radians().sin());
213            if i == 0 {
214                pb.move_to(x, y);
215            } else {
216                pb.line_to(x, y);
217            }
218        }
219        let Some(path) = pb.finish() else { return };
220        let mut paint = Paint::default();
221        paint.set_color_rgba8(color.r, color.g, color.b, color.a);
222        paint.anti_alias = true;
223        let stroke = Stroke { width: self.s(line_w), line_cap: tiny_skia::LineCap::Round, ..Stroke::default() };
224        self.pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
225    }
226
227    /// 多边形:`pts` 为逻辑坐标顶点;可填充、可描边(或都给)。
228    pub fn polygon(&mut self, pts: &[(f32, f32)], fill: Option<Color>, stroke: Option<(f32, Color)>) {
229        if pts.len() < 2 {
230            return;
231        }
232        let Some(path) = self.poly_path(pts) else { return };
233        if let Some(c) = fill {
234            if c.a > 0 {
235                let mut paint = Paint::default();
236                paint.set_color_rgba8(c.r, c.g, c.b, c.a);
237                paint.anti_alias = true;
238                self.pix.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
239            }
240        }
241        if let Some((lw, c)) = stroke {
242            if lw > 0.0 && c.a > 0 {
243                let mut paint = Paint::default();
244                paint.set_color_rgba8(c.r, c.g, c.b, c.a);
245                paint.anti_alias = true;
246                let st = Stroke { width: self.s(lw), line_join: tiny_skia::LineJoin::Round, ..Stroke::default() };
247                self.pix.stroke_path(&path, &paint, &st, Transform::identity(), None);
248            }
249        }
250    }
251
252    /// 雷达图:以 `(cx, cy)` 为心、`r` 为外接半径,`values`(各轴 0..1)画数据多边形 + 网格。
253    pub fn radar(&mut self, cx: f32, cy: f32, r: f32, values: &[f32], st: &Radar) {
254        let n = values.len();
255        if n < 3 || r <= 0.0 {
256            return;
257        }
258        let angle = |i: usize| (st.start_deg + 360.0 * i as f32 / n as f32).to_radians();
259        // 网格同心多边形。
260        let rings = st.rings.max(1);
261        for ring in 1..=rings {
262            let rr = r * ring as f32 / rings as f32;
263            let pts: Vec<(f32, f32)> = (0..n).map(|i| (cx + rr * angle(i).cos(), cy + rr * angle(i).sin())).collect();
264            self.polygon(&pts, None, Some((st.grid_w, st.grid)));
265        }
266        // 轴辐。
267        for i in 0..n {
268            let (ex, ey) = (cx + r * angle(i).cos(), cy + r * angle(i).sin());
269            self.line(cx, cy, ex, ey, st.grid_w, st.grid);
270        }
271        // 数据多边形。
272        let data: Vec<(f32, f32)> = (0..n)
273            .map(|i| {
274                let v = values[i].clamp(0.0, 1.0);
275                (cx + r * v * angle(i).cos(), cy + r * v * angle(i).sin())
276            })
277            .collect();
278        self.polygon(&data, Some(st.fill), Some((st.stroke_w, st.stroke)));
279        if let Some((dr, dc)) = st.vertex_dot {
280            for &(x, y) in &data {
281                self.disc(x, y, dr, dc);
282            }
283        }
284    }
285
286    /// 把一段样式化段落渲成透明底小图并合成到 `(x, y)`,占位宽 `box_w`(逻辑像素;段落对齐在此宽内生效)。
287    /// 返回该文本盒的渲染高度(逻辑像素),便于纵向流式排版。复用整条版式管线(CJK 整形 / 抗锯齿白拿)。
288    pub fn text(
289        &mut self,
290        x: f32,
291        y: f32,
292        box_w: f32,
293        opts: &RenderOptions,
294        build: impl FnOnce(&mut crate::build::ParaBuilder),
295    ) -> Result<f32> {
296        let mut doc = Doc::new();
297        doc.paragraph(build);
298        self.text_doc(x, y, box_w, opts, &doc.build())
299    }
300
301    /// 同 [`text`](Self::text),但直接给一份 [`Document`](crate::Document)(可多段 / 表格等)。
302    pub fn text_doc(&mut self, x: f32, y: f32, box_w: f32, opts: &RenderOptions, doc: &crate::Document) -> Result<f32> {
303        let img = render_text_block(doc, opts, box_w, self.scale)?;
304        let h = img.height() as f32 / self.scale;
305        self.blit(&img, self.s(x).round() as i32, self.s(y).round() as i32);
306        Ok(h)
307    }
308
309    /// 同 [`text`](Self::text),但把文字的**实际墨迹**纵向居中于中线 `cy`(逻辑像素),`x` 仍是左缘。给
310    /// 「标签 / 数值 / 进度条 / 圆牌同一行居中」的卡片行用——按行盒居中会偏高(行盒底部留白多),故取首末非透明
311    /// 行的中点对齐 `cy`,文字与同心线上的形状才真正齐平,不同字号也一致。
312    ///
313    /// 返回墨迹**右缘相对 `x` 的逻辑宽度**(全透明返回 0):据此把下一段接着往右摆,且每段各自居中于同一 `cy`,
314    /// 不会因不同字号共用基线而让小字下沉。
315    pub fn text_mid(
316        &mut self,
317        x: f32,
318        cy: f32,
319        box_w: f32,
320        opts: &RenderOptions,
321        build: impl FnOnce(&mut crate::build::ParaBuilder),
322    ) -> Result<f32> {
323        let mut doc = Doc::new();
324        doc.paragraph(build);
325        let img = render_text_block(&doc.build(), opts, box_w, self.scale)?;
326        let (ink_cy, advance) = match ink_box(&img) {
327            Some((_, y0, x1, y1)) => ((y0 + y1) as f32 / 2.0, (x1 + 1) as f32 / self.scale),
328            None => (img.height() as f32 / 2.0, 0.0),
329        };
330        let py = (self.s(cy) - ink_cy).round() as i32;
331        self.blit(&img, self.s(x).round() as i32, py);
332        Ok(advance)
333    }
334
335    /// 把一张 RGBA 图按物理像素坐标 `(px, py)` 直接合成(source-over,不缩放)。
336    pub fn blit(&mut self, img: &RgbaImage, px: i32, py: i32) {
337        if img.width() == 0 || img.height() == 0 {
338            return;
339        }
340        let Some(src) = rgba_to_pixmap(img) else { return };
341        self.pix.draw_pixmap(px, py, src.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
342    }
343
344    /// 按 `format` 编码为图片字节。
345    pub fn encode(&self, format: OutputFormat) -> Result<Vec<u8>> {
346        crate::paint::encode_pixmap(&self.pix, format)
347    }
348
349    /// 取(去预乘的)RGBA 图,供进一步合成。
350    pub fn into_rgba(self) -> Result<RgbaImage> {
351        let (w, h) = (self.pix.width(), self.pix.height());
352        RgbaImage::from_raw(w, h, crate::paint::pixmap_to_rgba_bytes(&self.pix))
353            .ok_or_else(|| Error::Layout("RGBA 缓冲尺寸不符".into()))
354    }
355
356    // —— 私有路径助手(逻辑坐标 → 物理) ——
357
358    fn fill_rrect(&mut self, x: f32, y: f32, w: f32, h: f32, radius: f32, paint: &Paint) {
359        if let Some(path) = self.rrect_path(x, y, w, h, radius) {
360            self.pix.fill_path(&path, paint, FillRule::Winding, Transform::identity(), None);
361        }
362    }
363
364    fn rrect_path(&self, x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<tiny_skia::Path> {
365        rrect_path_px(self.s(x), self.s(y), self.s(w), self.s(h), self.s(radius))
366    }
367
368    fn poly_path(&self, pts: &[(f32, f32)]) -> Option<tiny_skia::Path> {
369        let mut pb = PathBuilder::new();
370        pb.move_to(self.s(pts[0].0), self.s(pts[0].1));
371        for &(x, y) in &pts[1..] {
372            pb.line_to(self.s(x), self.s(y));
373        }
374        pb.close();
375        pb.finish()
376    }
377}
378
379/// 物理坐标的(可圆角)矩形路径。
380fn rrect_path_px(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia::Path> {
381    if w <= 0.0 || h <= 0.0 {
382        return None;
383    }
384    let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
385    if r <= 0.0 {
386        return Rect::from_xywh(x, y, w, h).and_then(|rect| {
387            let mut pb = PathBuilder::new();
388            pb.push_rect(rect);
389            pb.finish()
390        });
391    }
392    let k = r * 0.552_285; // 圆弧三次贝塞尔近似
393    let mut pb = PathBuilder::new();
394    pb.move_to(x + r, y);
395    pb.line_to(x + w - r, y);
396    pb.cubic_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r);
397    pb.line_to(x + w, y + h - r);
398    pb.cubic_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h);
399    pb.line_to(x + r, y + h);
400    pb.cubic_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r);
401    pb.line_to(x, y + r);
402    pb.cubic_to(x, y + r - k, x + r - k, y, x + r, y);
403    pb.close();
404    pb.finish()
405}
406
407/// 物理坐标的圆形路径。
408fn oval_path_px(cx: f32, cy: f32, r: f32) -> Option<tiny_skia::Path> {
409    let rect = Rect::from_xywh(cx - r, cy - r, r * 2.0, r * 2.0)?;
410    let mut pb = PathBuilder::new();
411    pb.push_oval(rect);
412    pb.finish()
413}
414
415/// 把一份文档渲成透明底、零边距、无页眉脚的 RGBA 小图(给画布合成文字用)。
416fn render_text_block(doc: &crate::Document, base: &RenderOptions, box_w: f32, scale: f32) -> Result<RgbaImage> {
417    let mut o = base.clone();
418    o.width = box_w.max(1.0);
419    o.padding = Insets::all(0.0);
420    o.scale = scale;
421    o.header = None;
422    o.footer = None;
423    o.theme.background = Color::rgba(0, 0, 0, 0); // 透明底,只留字
424    let layout = crate::layout::layout_document(doc, &o)?;
425    crate::paint::paint_rgba(&layout, &o)
426}
427
428/// 一张透明底文字图的**墨迹包围盒**(物理像素,含端点 `(x0, y0, x1, y1)`):任意通道 alpha 超阈值即算墨迹。
429/// 全透明返回 `None`。供文字纵向居中(取 y 中点)与按实宽接排(取 x 右缘)共用。
430fn ink_box(img: &RgbaImage) -> Option<(u32, u32, u32, u32)> {
431    let (w, h) = (img.width(), img.height());
432    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
433    let mut any = false;
434    for y in 0..h {
435        for x in 0..w {
436            if img.get_pixel(x, y).0[3] > 12 {
437                any = true;
438                x0 = x0.min(x);
439                y0 = y0.min(y);
440                x1 = x1.max(x);
441                y1 = y1.max(y);
442            }
443        }
444    }
445    any.then_some((x0, y0, x1, y1))
446}
447
448/// `image::RgbaImage`(直 alpha)→ 预乘 `Pixmap`。
449fn rgba_to_pixmap(img: &RgbaImage) -> Option<Pixmap> {
450    let mut p = Pixmap::new(img.width(), img.height())?;
451    let buf = p.pixels_mut();
452    for (i, px) in img.pixels().enumerate() {
453        let [r, g, b, a] = px.0;
454        let pm = |c: u8| ((c as u16 * a as u16 + 127) / 255) as u8;
455        buf[i] = PremultipliedColorU8::from_rgba(pm(r), pm(g), pm(b), a)
456            .unwrap_or_else(|| PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap());
457    }
458    Some(p)
459}
460
461fn skia(c: Color) -> tiny_skia::Color {
462    tiny_skia::Color::from_rgba8(c.r, c.g, c.b, c.a)
463}