tiny-skia-moon

    A tiny Skia subset ported to MoonBit (port of tiny-skia 0.12.0; see NOTICE)

    skia
    2d
    rendering
    Download zip
    Version
    0.1.0
    License
    BSD-3-Clause
    Last updated
    23 hours ago
    Downloads
    8

    #TryAndRun-TvT/tiny-skia-moon

    基于 MoonBit 的 2D 光栅渲染库(Skia 子集)。 纯 MoonBit 实现;渲染路径无第三方依赖(唯一直接依赖是 png-moon:基线读侧用它把上游 PNG 解回像素,公开面的 PNG 字节编解码也交给它,不参与出图)。 移植自 tiny-skia 0.12.0(BSD-3-Clause)。 上游版权、改动声明与归属见 NOTICE。

    star fork license

    本页是落地页:只给「能做什么 / 怎么用 / 去哪看细节」,不写会漂的计数。 能力边界在 T0 §2、 判据与账在 T0 §3 §1、 条数现算:bash scripts/test.sh · bash scripts/gates.sh · bash scripts/evidence-count.sh --check。

    语言MoonBit(后端:wasm 与 wasm-gc 并列,两个都验过,默认产物 wasm-gc)
    后端承诺两个都承诺:各自 build + check + test 全绿(bash scripts/backend-tier.sh),backend-parity 再跑 CLI 指纹逐字节一致。判据与分级方式见 T0 §3.10
    许可BSD-3-Clause(接收自 tiny-skia)
    来源tiny-skia 0.12.0 的 MoonBit 移植 → NOTICE
    测试黑盒 / 白盒分层 → 文档索引 · 现行口径 T0 事实基线

    数字与复跑命令在 T0 §6.4 实测值清单。


    #目录


    #功能特性

    • 纯 MoonBit 实现,无外部依赖,可直接 moon add 依赖到你的项目。
    • 分层内核:lib/internal 只做零分配计算,lib 承担公共类型与错误面。
    • 两个后端,都验过:wasm 与 wasm-gc(默认产物是 wasm-gc——体积小、性能好, 宿主需支持 GC 提案;宿主没有 GC 提案时走 wasm)。 但「声明了两个」不等于「两个都还能编」:见上方「后端承诺」一行——分级的判据是 bash scripts/backend-tier.sh,多一个后端、少一个后端、或某个后端被依赖掐掉,门禁都会红。 这条判据不是摆设:它真的红过(基线读侧的依赖曾只声明 wasm-gc,把 wasm 在解析期掐掉), 现在两段都绿——依赖 0.1.2 补齐了声明,本仓代码一行没动。
    • 错误是返回值不是异常:可失败 API 一律返回 Result[_, E]——调用方拿得到结构化错误, 刻意不改成 raise(判据见 T0 §4)。
    • 可复跑不是「能跑」,是「跑出同一个东西」:账本与基线产物「跑两遍逐字节相同」是一条门禁 (判据见 T0 §3.8)。

    • 能力边界(读之前先看这张表):

      能做什么状态
      填充任意路径(直线 / 二次 / 三次贝塞尔),Winding 与 EvenOdd✅
      fill_rect(与 fill_path 是两个入口,非整数坐标差 1 像素列)✅
      抗锯齿(4×4 超采样)· 非恒等变换 · 29 种混合模式✅
      遮罩 Mask(含 apply_mask 与 from_pixmap 的 Alpha / Luminance 两档,上游唯一不经过 Paint 的管线)✅
      描边:粗线 / 细线(w < 1px)/ 虚线(Stroke::with_dash)✅
      渐变四种形状:线性(两停 + 多停)· 径向(同心 / Strip / Focal)· 扫掠 · 图案✅
      低精度管线(lowp)✅ 按上游选路走;选路表 5 项已对齐并在装配层生效(T0 §2.6.3);AA + 图案三张基线逐字节
      大图(任一边长 > 8191,即 8192+)✅ 与上游 DrawTiler 同门限、同分块;8191 正常画,8192+ 逐块 subpixmap / submask(T0 §2.6.1)
      视图 / 复制 API(PixmapRef · clone_rect · from_bytes)✅ PixmapView 的 buffer / data / clone_rect / from_bytes / to_owned + Pixmap::clone_rect + PixmapMut::from_bytes / to_owned
      PNG 字节编解码(Pixmap / Mask / PixmapView)🟡 解 / 编已接(含调色板 PNG);load_png / save_png 要文件系统,wasm / wasm-gc 上不可用
      色彩空间(Paint.with_colorspace / ColorSpace):Linear(默认)· Gamma2 · SimpleSRGB · FullSRGBGamma✅ 纯色 / 渐变的展开与压缩都接上了;gamma.png 逐字节已认领(V-5,T6 §1.5.1.2)
      JPEG❌ 不做(上游也没有;无素材、无判据)

      逐条状态与判据命令在 T0 §2, 不在这里抄第二份——本表只是给调用方的一页索引。

      Err 而不是静默忽略:静默会让「本该画出来但没画」变成一条永远绿的测试。

      API 面不是 1:1:部分内核方法还没经 lib 转发,只 import lib 的消费者调不到 (类型 re-export 不搬方法)。逐条账与复跑探针在 T0 §2.5,这里不写计数——数会漂。

    移植范围、逐文件映射与里程碑见 T0 · 事实基线 §2(能力边界)· §1(还差什么);逐文件映射在 T0 §4.2。


    #快速开始

    #示例索引

    下面每个代码块都是可直接复制的 mbt check 文档测试,按能力顺序排列:

    能力示例
    标量入口quick start
    路径填充fill a triangle
    抗锯齿 · 混合模式fill an antialiased rect with Xor
    非恒等变换fill a rect through a non-identity transform
    遮罩fill a path through a mask
    描边:粗线stroke a thick line
    描边:虚线stroke a dashed line
    渐变:线性两停 / 多停paint with a linear gradient · paint with a multi-stop linear gradient
    渐变:径向paint with a radial gradient
    渐变:扫掠paint with a sweep gradient
    图案着色器paint with a pattern
    色彩空间select a color space by name
    PNG 字节编解码png bytes round trip

    ///|
    test "quick start" {
    // 版本号来自 moon.mod,是单一事实源
    // 用码点数判长度:`String::length()` 等于已弃用的 `count()`,语义随工具链漂移
    let v = @lib.version()
    assert_true(v.to_array().length() > 0)

    // 通道值钳制:0..=255
    assert_eq(@lib.clamp_channel(300), 255)
    assert_eq(@lib.clamp_channel(-1), 0)

    // 单位区间钳制:[0, 1]
    assert_eq(@lib.clamp_unit(2.0), 1.0)
    assert_eq(@lib.clamp_unit(-0.5), 0.0)
    }

    画一个三角形(M3:抗锯齿 + 20 种混合模式都可用,这里用默认的 SourceOver):

    ///|
    test "fill a triangle" {
    let pm = @lib.Pixmap::new(64, 64).unwrap()
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 50, 127, 150, 200).with_anti_alias(
    false,
    )
    let pb = @lib.path_builder_new()
    @lib.path_builder_move_to(pb, 32.0, 4.0)
    @lib.path_builder_line_to(pb, 60.0, 60.0)
    @lib.path_builder_line_to(pb, 4.0, 60.0)
    @lib.path_builder_close(pb)
    let path = @lib.path_builder_finish(pb).unwrap()
    let r = pm.fill_path(
    path,
    paint,
    @lib.fill_rule_winding(),
    @lib.transform_identity(),
    )
    assert_true(r is Ok(_))

    // 三角形内部不透明,外部透明:像素是**预乘** RGBA
    match pm.pixel_at(32, 40) {
    Some((red, _, _, alpha)) => {
    // premultiply_u8(50, 200):prod = 50*200+128 = 10128;10128 + (10128>>8) = 10167;
    // 10167 >> 8 = 39
    assert_eq(red, 39)
    assert_eq(alpha, 200)
    }
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(0, 0) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail("左上角应为透明,实际 (\{red},\{green},\{blue},\{alpha})")
    None => fail("pixel_at 越界")
    }
    }

    画一个抗锯齿的矩形,并用 Xor 混合(M3 的能力):

    ///|
    test "fill an antialiased rect with Xor" {
    let pm = @lib.Pixmap::new(16, 16).unwrap()
    // 先铺一层不透明底,好让混合模式有东西可混
    let bg = @lib.paint_with_rgba8(@lib.paint_new(), 200, 100, 50, 255).with_anti_alias(
    false,
    )
    let full = @lib.rect_from_ltrb(0.0, 0.0, 16.0, 16.0).unwrap()
    assert_true(pm.fill_rect(full, bg, @lib.transform_identity()) is Ok(_))

    // 再用 Xor 画一个分数边界的矩形(上下边落在半像素上 ⇒ 出覆盖率)
    let fg = @lib.paint_with_rgba8(@lib.paint_new(), 20, 210, 160, 255).with_anti_alias(
    true,
    )
    let xor = @lib.paint_with_blend_mode(fg, "Xor").unwrap()
    let r = @lib.rect_from_ltrb(2.5, 4.25, 12.5, 11.75).unwrap()
    assert_true(pm.fill_rect(r, xor, @lib.transform_identity()) is Ok(_))

    // 矩形正中是不透明区:Xor 对「不透明源 + 不透明底」给出 (0,0,0,0)
    match pm.pixel_at(7, 8) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "Xor 正中应为全透明,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    // 边界行必须出现**中间覆盖率**(既不是 0 也不是 1)——这就是抗锯齿在起作用
    let mut saw_partial = false
    for x = 3; x < 12; x = x + 1 {
    match pm.pixel_at(x, 4) {
    Some((_, _, _, alpha)) =>
    if alpha > 0 && alpha < 255 {
    saw_partial = true
    }
    None => fail("pixel_at 越界")
    }
    }
    assert_true(saw_partial)
    }

    给填充挂一个非恒等变换(能力表里的「非恒等变换」就是这条:transform_from_translate 把几何先映射再光栅化):

    ///|
    test "fill a rect through a non-identity transform" {
    let pm = @lib.Pixmap::new(16, 1).unwrap()
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 10, 20, 30, 255).with_anti_alias(
    false,
    )
    // 变换的构造与读数都在公开面:平移 (5,0) 不是恒等变换,带平移、不带缩放
    let ts = @lib.transform_from_translate(5.0, 0.0)
    assert_true(!@lib.transform_is_identity(ts))
    assert_true(@lib.transform_has_translate(ts))
    assert_true(!@lib.transform_has_scale(ts))
    // 几何先过变换再光栅化:矩形 [0,4)×[0,1) 落到 x ∈ [5,9)
    let rect = @lib.rect_from_ltrb(0.0, 0.0, 4.0, 1.0).unwrap()
    assert_true(pm.fill_rect(rect, paint, ts) is Ok(_))
    match pm.pixel_at(0, 0) {
    Some((0, 0, 0, 0)) => ()
    Some(_) => fail("fill_rect + from_translate(5,0):x=0 应透明")
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(6, 0) {
    Some((10, 20, 30, 255)) => ()
    Some(_) =>
    fail("fill_rect + from_translate(5,0):x=6 应为不透明源色")
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(12, 0) {
    Some((0, 0, 0, 0)) => ()
    Some(_) =>
    fail(
    "fill_rect + from_translate(5,0):x=12 在矩形右边界外,应透明",
    )
    None => fail("pixel_at 越界")
    }
    }

    画一条被遮罩裁掉一半的路径(M3.5 的能力:mask_* 造覆盖率,*_with_mask 让它生效):

    ///|
    test "fill a path through a mask" {
    let pm = @lib.Pixmap::new(32, 32).unwrap()
    // 遮罩:只放行左半 `[0,16)`
    let mask = @lib.mask_new(32, 32).unwrap()
    let half = @lib.path_builder_new()
    @lib.path_builder_move_to(half, 0.0, 0.0)
    @lib.path_builder_line_to(half, 16.0, 0.0)
    @lib.path_builder_line_to(half, 16.0, 32.0)
    @lib.path_builder_line_to(half, 0.0, 32.0)
    @lib.path_builder_close(half)
    let half_path = @lib.path_builder_finish(half).unwrap()
    assert_true(
    @lib.mask_fill_path(
    mask,
    half_path,
    @lib.fill_rule_winding(),
    false,
    @lib.transform_identity(),
    )
    is Ok(_),
    )
    // mask 里 255 / 0 就是「放行 / 拦住」
    assert_eq(@lib.mask_coverage_at(mask, 0, 0), 255)
    assert_eq(@lib.mask_coverage_at(mask, 16, 0), 0)

    // 用整张图去填,但经过遮罩 ⇒ 只有左半被画
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 50, 127, 150, 255).with_anti_alias(
    false,
    )
    let full = @lib.path_builder_new()
    @lib.path_builder_move_to(full, 0.0, 0.0)
    @lib.path_builder_line_to(full, 32.0, 0.0)
    @lib.path_builder_line_to(full, 32.0, 32.0)
    @lib.path_builder_line_to(full, 0.0, 32.0)
    @lib.path_builder_close(full)
    let full_path = @lib.path_builder_finish(full).unwrap()
    assert_true(
    @lib.pixmap_fill_path_with_mask(
    pm,
    full_path,
    paint,
    @lib.fill_rule_winding(),
    @lib.transform_identity(),
    mask,
    )
    is Ok(_),
    )
    // 遮罩内:不透明源色(a=255 ⇒ 预乘就是自己)
    match pm.pixel_at(4, 16) {
    Some((10, 20, 30, 255)) => fail("不该是别的颜色")
    Some((50, 127, 150, 255)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "遮罩内应为 (50,127,150,255),实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    // 遮罩外:一个字节都没被碰
    match pm.pixel_at(20, 16) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail("遮罩外应透明,实际 (\{red},\{green},\{blue},\{alpha})")
    None => fail("pixel_at 越界")
    }
    }

    描一条粗线(M4.5 轮 A:stroke_path 入口已接,粗细走 stroker 生成外扩轮廓再填充):

    ///|
    test "stroke a thick line" {
    let pm = @lib.Pixmap::new(100, 100).unwrap()
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 50, 127, 150, 200).with_anti_alias(
    false,
    )
    let pb = @lib.path_builder_new()
    @lib.path_builder_move_to(pb, 20.0, 50.0)
    @lib.path_builder_line_to(pb, 80.0, 50.0)
    let path = @lib.path_builder_finish(pb).unwrap()

    // w = 10、Butt 端:沿法向各外扩 w/2 = 5 ⇒ 覆盖 x ∈ [20, 80]、y ∈ [45, 55)
    // (Butt 不沿切线延长,所以左端就是端点那一格;像素 y 覆盖 [y, y+1) 是半开区间)
    let stroke = @lib.stroke_new()
    .with_width(10.0)
    .with_line_cap(@lib.line_cap_butt())
    assert_true(
    pm.stroke_path(path, paint, stroke, @lib.transform_identity()) is Ok(_),
    )

    // 五个「几何算术」点:中线不透明、两端按 Butt 截断、上下边界外各差一格
    match pm.pixel_at(50, 50) {
    // premultiply_u8:prod = c*200+128,结果 (prod + (prod>>8)) >> 8
    // 50 ⇒ 10128 ⇒ 10167 >> 8 = 39 · 127 ⇒ 25528 ⇒ 25627 >> 8 = 100 · 150 ⇒ 30128 ⇒ 30245 >> 8 = 118
    Some((39, 100, 118, 200)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "中线应为预乘 (39,100,118,200),实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(50, 44) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "y=44 在 w=10 的带外,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    // 半开区间:像素 y 覆盖 [y, y+1),所以带是 y ∈ [45, 55) ⇒ 54 在内、55 在外
    match pm.pixel_at(50, 54) {
    Some((39, 100, 118, 200)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "y=54 在 w=10 的带内,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(50, 55) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "y=55 在 w=10 的带外,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    // Butt:x = 19 在端点外(不延长),x = 20 在端点上(闭区间)
    match pm.pixel_at(19, 50) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "Butt 端外 x=19 应透明,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(20, 50) {
    Some((39, 100, 118, 200)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "Butt 端点 x=20 应不透明,实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    }

    描一条虚线(M4.5 最后一块:Stroke::with_dash,切完再按线宽分粗线 / 细线两条路):

    ///|
    test "stroke a dashed line" {
    let pm = @lib.Pixmap::new(100, 20).unwrap()
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 0, 0, 0, 255).with_anti_alias(
    false,
    )
    let pb = @lib.path_builder_new()
    @lib.path_builder_move_to(pb, 0.0, 10.0)
    @lib.path_builder_line_to(pb, 100.0, 10.0)
    let path = @lib.path_builder_finish(pb).unwrap()

    // dash 数组是「实、虚、实、虚…」,相位 0:实 10、虚 10
    let dash = @lib.stroke_dash_new([6.0, 10.0], 0.0).unwrap()
    let stroke = @lib.stroke_new().with_width(4.0).with_dash(Some(dash))
    assert_true(
    pm.stroke_path(path, paint, stroke, @lib.transform_identity()) is Ok(_),
    )

    // 实段在前 10 像素里 ⇒ x=5 不透明;x=15 落在虚段里 ⇒ 一个字节都没被碰
    match pm.pixel_at(5, 10) {
    Some((0, 0, 0, 255)) => ()
    Some((red, green, blue, alpha)) =>
    fail(
    "实段 x=5 应为 (0,0,0,255),实际 (\{red},\{green},\{blue},\{alpha})",
    )
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(15, 10) {
    Some((0, 0, 0, 0)) => ()
    Some((red, green, blue, alpha)) =>
    fail("虚段 x=15 应透明,实际 (\{red},\{green},\{blue},\{alpha})")
    None => fail("pixel_at 越界")
    }
    // 非法 dash 是**返回值**不是异常:空数组 / 奇数长度 / 负数都拿 `Err`
    assert_true(@lib.stroke_dash_new([], 0.0) is Err(_))
    assert_true(@lib.stroke_dash_new([5.0], 0.0) is Err(_))
    assert_true(@lib.stroke_dash_new([5.0, -1.0], 0.0) is Err(_))
    }

    挂一条线性渐变着色器(M5:Paint.shader 是 Shader?,None 就是纯色):

    ///|
    test "paint with a linear gradient" {
    let pm = @lib.Pixmap::new(8, 1).unwrap()
    // 沿 x 从 (0,0) 到 (8,0):红 → 蓝,Pad(范围外复用首末色)
    let grad = @lib.paint_with_linear_gradient(
    @lib.paint_new(),
    0.0,
    0.0,
    8.0,
    0.0,
    @lib.Color::from_rgba8(255, 0, 0, 255),
    @lib.Color::from_rgba8(0, 0, 255, 255),
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    ).unwrap()
    // 挂着色器时它**不是**纯色:`memset2d` 短路与强度归约都以 `is_solid_color` 为前提
    assert_true(!grad.is_solid_color())
    // 两个不同着色器挂在同一个 Paint 上 ⇒ 后设的生效(上游 `paint.shader = …` 是赋值)
    let again = @lib.paint_with_shader(grad, grad.shader().unwrap())
    assert_eq(@lib.shader_kind_name(again.shader().unwrap()), "LinearGradient")
    // 设纯色会把着色器撤掉(上游 `set_color` 写的是 `shader = SolidColor(c)`)
    let solid = grad.with_color_rgba8(0, 200, 0, 255)
    assert_true(solid.is_solid_color())
    assert_true(solid.shader() is None)

    let full = @lib.rect_from_ltrb(0.0, 0.0, 8.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, grad, @lib.transform_identity()) is Ok(_))
    // 渐变必须真的铺开:两端各偏向自己那侧
    match pm.pixel_at(0, 0) {
    Some((red, _, blue, _)) => assert_true(red > 200 && blue < 60)
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(7, 0) {
    Some((red, _, blue, _)) => assert_true(red < 60 && blue > 200)
    None => fail("pixel_at 越界")
    }
    }

    多停(三个以上色标,或位置不落在 0 / 1)用同一批管线阶段,入口换一个:

    ///|
    test "paint with a multi-stop linear gradient" {
    let pm = @lib.Pixmap::new(8, 1).unwrap()
    // 0.0 红 → 0.5 绿 → 1.0 蓝。三停走 `Gradient`(查表),两停才走
    // `EvenlySpaced2StopGradient`(四次 mad)——上游的判据是色标数,不是「有没有中间色」。
    let stops : Array[(Float, @lib.Color)] = [
    (0.0, @lib.Color::from_rgba8(255, 0, 0, 255)),
    (0.5, @lib.Color::from_rgba8(0, 255, 0, 255)),
    (1.0, @lib.Color::from_rgba8(0, 0, 255, 255)),
    ]
    let grad = @lib.paint_with_linear_gradient_stops(
    @lib.paint_new(),
    0.0,
    0.0,
    8.0,
    0.0,
    stops,
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    ).unwrap()
    let full = @lib.rect_from_ltrb(0.0, 0.0, 8.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, grad, @lib.transform_identity()) is Ok(_))
    // 两端是首末色标,中间那停真的参与插值(绿通道在中间最大)
    match pm.pixel_at(0, 0) {
    Some((red, green, blue, _)) =>
    assert_true(red > 200 && green < 60 && blue < 60)
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(3, 0) {
    Some((_, green, _, _)) => assert_true(green > 150)
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(7, 0) {
    Some((red, green, blue, _)) =>
    assert_true(red < 60 && green < 60 && blue > 200)
    None => fail("pixel_at 越界")
    }
    // 位置越界**不报错**:上游 `GradientStop::new` 用 `new_clamped` 夹住;空数组才是 `Err`
    let clamped : Array[(Float, @lib.Color)] = [
    (-1.0, @lib.Color::from_rgba8(255, 0, 0, 255)),
    (2.0, @lib.Color::from_rgba8(0, 0, 255, 255)),
    ]
    assert_true(
    @lib.paint_with_linear_gradient_stops(
    @lib.paint_new(),
    0.0,
    0.0,
    8.0,
    0.0,
    clamped,
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    )
    is Ok(_),
    )
    let empty : Array[(Float, @lib.Color)] = []
    assert_true(
    @lib.paint_with_linear_gradient_stops(
    @lib.paint_new(),
    0.0,
    0.0,
    8.0,
    0.0,
    empty,
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    )
    is Err(_),
    )
    }

    径向渐变与扫掠渐变共用同一批入口形状(圆心 + 半径 / 圆心 + 角度):

    ///|
    test "paint with a radial gradient" {
    let pm = @lib.Pixmap::new(8, 1).unwrap()
    // 同心径向:(4,0) 为心,r0 = 0 → r1 = 4:红 → 蓝,Pad
    let grad = @lib.paint_with_radial_gradient(
    @lib.paint_new(),
    4.0,
    0.0,
    0.0,
    4.0,
    0.0,
    4.0,
    @lib.Color::from_rgba8(255, 0, 0, 255),
    @lib.Color::from_rgba8(0, 0, 255, 255),
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    ).unwrap()
    let full = @lib.rect_from_ltrb(0.0, 0.0, 8.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, grad, @lib.transform_identity()) is Ok(_))
    // 圆心是首色(t = 0),最外缘是末色(t = 1)
    match pm.pixel_at(4, 0) {
    Some((red, _, blue, _)) => assert_true(red > 200 && blue < 60)
    None => fail("pixel_at 越界")
    }
    match pm.pixel_at(0, 0) {
    Some((red, _, blue, _)) => assert_true(red < 60 && blue > 200)
    None => fail("pixel_at 越界")
    }
    // 半径为负是返回值不是异常:Err(NegativeRadius)
    assert_true(
    @lib.paint_with_radial_gradient(
    @lib.paint_new(),
    4.0,
    0.0,
    -1.0,
    4.0,
    0.0,
    4.0,
    @lib.Color::from_rgba8(255, 0, 0, 255),
    @lib.Color::from_rgba8(0, 0, 255, 255),
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    )
    is Err(_),
    )
    }

    扫掠渐变(M5:与径向不同的入参是「圆心 + 起始角 + 终止角」,角度制;满圈 0..360 会把铺展模式强制成 Pad):

    ///|
    test "paint with a sweep gradient" {
    let pm = @lib.Pixmap::new(8, 1).unwrap()
    // 圆心 (4,0),红起蓝止;t0/t1 = 起始角/360、终止角/360
    let grad = @lib.paint_with_sweep_gradient(
    @lib.paint_new(),
    4.0,
    0.0,
    0.0,
    360.0,
    @lib.Color::from_rgba8(255, 0, 0, 255),
    @lib.Color::from_rgba8(0, 0, 255, 255),
    @lib.spread_mode_pad(),
    @lib.transform_identity(),
    ).unwrap()
    // 挂了着色器就不是纯色;种别名是枚举的契约名
    assert_true(!grad.is_solid_color())
    assert_eq(@lib.shader_kind_name(grad.shader().unwrap()), "SweepGradient")
    // t0/t1 是扫掠独有的几何读数(线性和径向取不到)
    match @lib.sweep_gradient_t0_t1(grad.shader().unwrap()) {
    Some((t0, t1)) => {
    assert_true(t0 == 0.0)
    assert_true(t1 == 1.0)
    }
    None => fail("扫掠着色器应给得出 (t0, t1)")
    }
    let full = @lib.rect_from_ltrb(0.0, 0.0, 8.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, grad, @lib.transform_identity()) is Ok(_))
    // 圆心两侧的角度不同 ⇒ t 不同 ⇒ 颜色不同(不是一张纯色平铺)
    match (pm.pixel_at(0, 0), pm.pixel_at(7, 0)) {
    (Some((lr, lg, lb, la)), Some((rr, rg, rb, ra))) =>
    assert_true(lr != rr || lg != rg || lb != rb || la != ra)
    _ => fail("pixel_at 越界")
    }
    }

    用一张小图当图案着色器铺满画布(R-6:paint_with_pattern,opacity 会被夹到 [0, 1]):

    ///|
    test "paint with a pattern" {
    // 源图 2×1:左像素不透明红,右像素不透明蓝
    let src = @lib.Pixmap::new(2, 1).unwrap()
    let red = @lib.paint_with_rgba8(@lib.paint_new(), 255, 0, 0, 255)
    let blue = @lib.paint_with_rgba8(@lib.paint_new(), 0, 0, 255, 255)
    assert_true(
    src.fill_rect(
    @lib.rect_from_ltrb(0.0, 0.0, 1.0, 1.0).unwrap(),
    red,
    @lib.transform_identity(),
    )
    is Ok(_),
    )
    assert_true(
    src.fill_rect(
    @lib.rect_from_ltrb(1.0, 0.0, 2.0, 1.0).unwrap(),
    blue,
    @lib.transform_identity(),
    )
    is Ok(_),
    )

    // Repeat + Nearest:源图像素 → 画布走恒等变换,2 像素一循环
    let pat = @lib.paint_with_pattern(
    @lib.paint_new(),
    src,
    @lib.spread_mode_repeat(),
    @lib.filter_quality_nearest(),
    1.0,
    @lib.transform_identity(),
    ).unwrap()
    let pm = @lib.Pixmap::new(5, 1).unwrap()
    let full = @lib.rect_from_ltrb(0.0, 0.0, 5.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, pat, @lib.transform_identity()) is Ok(_))
    // x 偶数落红列、x 奇数落蓝列(不透明源 ⇒ 预乘值就是自己)
    assert_eq(pm.pixel_at(0, 0), Some((255, 0, 0, 255)))
    assert_eq(pm.pixel_at(1, 0), Some((0, 0, 255, 255)))
    assert_eq(pm.pixel_at(4, 0), Some((255, 0, 0, 255)))
    }

    给 Paint 换色彩空间(四档名字是契约,拼错返回 None,不静默取默认 Linear):

    ///|
    test "select a color space by name" {
    // 名字逐字照抄上游 ColorSpace 变体名;`Linear` 是默认档
    assert_true(@lib.colorspace_of_name("Linear") is Some(_))
    assert_true(@lib.colorspace_of_name("Gamma2") is Some(_))
    assert_true(@lib.colorspace_of_name("SimpleSRGB") is Some(_))
    assert_true(@lib.colorspace_of_name("FullSRGBGamma") is Some(_))
    assert_true(@lib.colorspace_of_name("sRGB") is None)
    // setter 同形:合法名字给 Paint,拼错给 None
    let base = @lib.paint_with_rgba8(@lib.paint_new(), 50, 100, 150, 255)
    assert_true(@lib.paint_with_colorspace(base, "Gamma2") is Some(_))
    assert_true(@lib.paint_with_colorspace(base, "bogus") is None)
    // 挂上色彩空间之后照常出图
    let pm = @lib.Pixmap::new(4, 1).unwrap()
    let paint = @lib.paint_with_colorspace(base, "Gamma2").unwrap()
    let full = @lib.rect_from_ltrb(0.0, 0.0, 4.0, 1.0).unwrap()
    assert_true(pm.fill_rect(full, paint, @lib.transform_identity()) is Ok(_))
    }

    PNG 字节编解码走一圈(load_png / save_png 要文件系统,wasm 上不可用; 字节版 decode_png / encode_png 两后端都可用):

    ///|
    test "png bytes round trip" {
    let pm = @lib.Pixmap::new(4, 2).unwrap()
    let paint = @lib.paint_with_rgba8(@lib.paint_new(), 50, 127, 150, 200).with_anti_alias(
    false,
    )
    assert_true(
    pm.fill_rect(
    @lib.rect_from_ltrb(0.0, 0.0, 4.0, 2.0).unwrap(),
    paint,
    @lib.transform_identity(),
    )
    is Ok(_),
    )
    // 编成 PNG 字节再解回来:逐像素一致
    let bytes = pm.encode_png().unwrap()
    let back = @lib.Pixmap::decode_png(bytes).unwrap()
    assert_eq(back.width(), 4)
    assert_eq(back.height(), 2)
    for y = 0; y < 2; y = y + 1 {
    for x = 0; x < 4; x = x + 1 {
    assert_eq(back.pixel_at(x, y), pm.pixel_at(x, y))
    }
    }
    // 坏字节是返回值不是异常
    assert_true(@lib.Pixmap::decode_png(b"not a png") is Err(_))
    }

    着色器的类型面(调用方只需记两件事):Paint.shader 是 Shader?——一个字段、一个枚举, None 表示纯色,所以「同时挂两个着色器」在类型上写不出来; with_color_rgba8 / with_color 会撤掉着色器(与上游 set_color 同义)。 为什么是这种形状、加成员的判据在哪,见 T0 §2.3。

    描边怎么走(只讲路径,不讲状态):粗线铺成外扩轮廓再当填充;细线(w < 1px 或 w = 0) 走 hairline / hairline_aa 的逐段扫描,不走轮廓;dash 在两者之前——先切虚线再判粗细 (与上游同序)。三条路各自的状态与判据在 T0 §2.2。

    上面这些代码块都是文档测试:moon test 会真编译、真运行。 三角形那段断言的是预乘值(50*200/255 的整数式舍入),不是「跑一遍抄下来」—— 算式写在注释里,手算可复核;抗锯齿矩形那段断言「边界行出现过中间覆盖率」, 这是抗锯齿唯一能被黑盒测到的特征(全 0 或全 255 说明 AA 没生效)。 新增三块也各有读数:变换判 from_translate(5,0) 把 [0,4) 搬到 [5,9) 的几何算术; 扫掠判 t0/t1 = 0/1 与「圆心两侧颜色不同」(不是纯色平铺); 色彩空间判名字契约——拼错给 None,不静默取默认 Linear。

    在依赖它的模块里使用:

    moon add TryAndRun-TvT/tiny-skia-moon

    改公共 API 必须同 PR 改这里的示例,否则 moon test 直接变红。


    #构建与运行

    # 环境初始化:一条命令装齐 moon + cc + make + cargo(幂等,装完自检) bash scripts/setup-toolchain.sh # 只跑门禁的话,SKIP_RUST=1 可以跳过 cc/cargo; # 只要 Rust 侧(上游取证)用 bash scripts/setup-rust.sh # # 为什么 cc 是必装:缺它时 `cargo build` 连 `crc32fast` 都编不过—— # 那个 crate 的 build script 要先编成宿主机二进制。报错指向 crate、不指向环境。 # 本地全量门禁(push CI 未启用时的等价替代) bash scripts/gates.sh # 只跑一段 STAGES="check test" bash scripts/gates.sh SKIP_SLOW=1 bash scripts/gates.sh # 工具链适配判据(换了工具链 / 改了 .moonignore 或发布面后先跑这个) bash scripts/toolchain-check.sh

    结论探针(只读,不进门禁;文档里的「实测」都由它们产出):

    bash scripts/lang-probe.sh # MoonBit 语言卡点 + SIMD 能力 + 标量基准 + ⑩/⑪ API 形态陷阱 bash scripts/upstream-ledger.sh # 上游账本:体量 / 测试 / 基线分布 / 结构常量(全部现算) bash scripts/upstream-semantics.sh # 上游语义:12 组判据逐条打印**上游行号** python3 scripts/gen-baselines.py --verify-only # 基线解码三判据(需上游已 clone) bash scripts/determinism.sh # 同命令跑两遍字节必须一样(账本 / 基线 / 指纹自检) bash scripts/m5-probe.sh # M5 可行性:未实现阶段分批 / 上游 lowp 表里哪些是 null_fn / 基线分布

    # 两个后端都编(缺省 preferred_target = wasm-gc) moon build cmd/main --target wasm-gc --release moon build cmd/main --target wasm --release moon run cmd/main --target wasm-gc # 公开面用法示例(纯色/路径填充 · 描边 · 线性渐变,结果打印采样像素) moon run cmd/examples --target wasm-gc # 从 JS 调用:cmd/jsapi 导出绑定函数,JS 建画布 → 传参画图 → 像素读回断言 # (真实交互演示,两个后端同一套导出名,详见 cmd/examples/js/README.md) moon build cmd/jsapi --target wasm && node cmd/examples/js/jsapi_demo.mjs wasm moon build cmd/jsapi --target wasm-gc && node cmd/examples/js/jsapi_demo.mjs wasm-gc # JS 加载 wasm / wasm-gc 产物(构建 + Node 运行,含两后端差异分析) moon build cmd/examples --target wasm-gc --release node cmd/examples/js/run-gc.mjs moon build cmd/examples --target wasm --release node cmd/examples/js/run-wasm.mjs # 后端等价性判据(声明集合 / 双后端 CLI 输出一致 / 逐后端 moon test) bash scripts/backend-parity.sh


    #文档索引

    门口在 docs/README.md(按读者路径索引)。 docs/ 只有一篇正文:T0 · 事实基线 ——口径、能力边界、事实基线、改代码前的三张表、缺口与下一步、判据工程、裁决、过程留痕, 以及「注释与死代码的账」(§3.14)和「基线形态与真值来源」(§3.15)。另加一个导航面 docs/README.md。 其余是 archive/ 里的指针(只写「已并入 T0 哪一节」,正文不在了)。 归档外目标用仓库绝对链接(/docs/ 与 /AGENTS.md 不进发布归档)。

    文档回答什么
    T0 · 事实基线唯一口径,全仓唯一的正文:一句话现状 · 能力边界(对着代码核过)· §3 十五节事实基线(上游账本 / 数值一致性 / 对拍口径 / 精度档 / 三张表 / 判据面 / 门禁 / 确定性 / 性能账 / 后端与工具链 / 许可 / 文档纪律 / 环境 / 注释与死代码 / 基线形态,每条一命令)· §4 改代码前的三张表与坑录 · §1 完整缺口清单 · §2 下一步与出口判据 · §3 判据工程十六种形态 · §4 裁决与否掉的方案 · §5 过程留痕 · §6 文档长度与索引(生成物)
    AGENTS.md硬性约定:Git 安全 · 包与文件放置 · 测试铁律 · 文档准入 · 环境初始化(写代码前先读)
    NOTICE · LICENSE上游归属与逐条改动声明(唯一出处;许可义务→判据见 T0 §3.11)

    历史存档:docs/archive/ 里 是指针(已失效,不删文件,只留「已并入哪一篇哪一节」)。篇数不写死,现算:bash scripts/docs-length.sh --print。 lib/** 注释里的 M1 §5.5 这类施工期章节号去那里按编号找—— 读法见 T6 §5.5。 要复盘「当初为什么这么判」才去读它们。


    #项目结构

    . ├── moon.mod # 模块元数据(版本号单一事实源) ├── moon.pkg # 模块根空包(README 文档测试宿主) ├── lib/ │ ├── moon.pkg # 公共面(导出 API,含路径面的再导) │ ├── facade.mbt # 公开形态:类型 + 错误 │ ├── path/ # 路径面子模块:Point/Rect/Size/Transform + Path/PathBuilder │ │ # + 描边与虚线。**不含光栅**,只往下依赖内核 │ └── internal/ # 零分配内核(不 import lib) │ ├── num/ # 饱和 / 回绕 / 定点 / 谓词 / 通道算术(M1) │ ├── raster/ # 边 / 扫描线 / 裁剪(M2)+ AlphaRuns / SuperBlitter / AA 矩形(M3) │ ├── mask/ # 遮罩:覆盖率缓冲 + fill_path / intersect_path(M3.5) │ ├── pipeline/ # 栅格管线:79 项 Stage 枚举 + 已认领阶段 + blitter(M3/M4/M5) │ ├── color/ # Color / ColorU8 / PremultipliedColorU8(M2) │ ├── pixmap/ # Pixmap + SubPixmapMut(不含 PNG 编解码) │ ├── error/ # KernelError(把上游 Option / panic 收成可穷尽枚举) │ └── fingerprint/ # SHA-256(只服务确定性判据) ├── lib/baselines/ # 上游像素基线(裸字节)+ 整图对拍脚手架 ├── cmd/main/ # CLI 演示入口(构建/运行 smoke 靶子) ├── cmd/fingerprint/ # 确定性指纹入口 ├── cmd/examples/ # 示例入口:填充 / 路径 / 描边 / 渐变的公开面用法 │ └── js/ # JS 加载 wasm / wasm-gc 产物的最小示例 + 两后端差异分析 ├── scripts/ # 门禁脚本(gates.sh 串成一条链) ├── docs/ # 文档(规格 / 证据 / 过程 / 元) └── NOTICE / LICENSE # 上游归属与许可正文(随包分发)


    #发布要素与可审计面

    • 元数据:moon.mod(name / version / license / readme / repository / description)。
    • 版本号单一事实源:moon.mod 的 version;README 不写死当前版本号。
    • 归属必须随包走:NOTICE 与 LICENSE 在归档内(moon package --list 可验),缺一即许可链断裂。
    • 门禁:bash scripts/gates.sh 一条命令跑全量,详见 T0 §3.7 门禁。


    #工程约定与贡献

    • 硬性约定见 AGENTS.md(Git 安全 / 包与文件放置 / 测试铁律 / 文档管理)。
    • 提交前跑 bash scripts/gates.sh;或启用本地钩子 git config core.hooksPath .githooks(个人本地配置,仓库不代设)。


    #致谢与许可

    本库是 tiny-skia 0.12.0 的 MoonBit 移植,上游为 Yevhenii Reizner 与贡献者开发,采用 BSD-3-Clause; tiny-skia 本身是 Skia(Google Inc.,BSD-3-Clause)的子集移植。

    依 BSD-3-Clause 条件③:不得以 tiny-skia / Skia / 其作者或贡献者名义为本移植背书。