折腾笔记[61]-typst添加导出字符级标签功能

摘要

给排版软件typst添加导出字符级标签功能.typst_plus 是基于上游 Typst 编译器的扩展分支, 新增图片导出增强和字符级标注两大功能。图片导出方面,在原有 PNG 基础上新增 JPEG 和 BMP 格式,支持通过 --ppi/--dpi 参数控制分辨率,并能根据文件扩展名自动识别输出格式。字符级标注方面,通过 --label-json 可生成 X-AnyLabeling 格式的 JSON 文件,包含逐字符定向边界框信息(shape_type 固定为 "rotation"points 为左上→右上→右下→左下的四顶点像素坐标,direction 固定为 0,imageData 采用 Base64 内嵌光栅图片),同时 --label-viz 可生成带红色定向边界框叠加的可视化图片,且完整支持 Typst 帧树中的旋转、缩放等任意几何变换,适用于 OCR 训练数据合成、文档理解模型标注等场景。

声明

本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.

工程仓库

[https://github.com/qsbye/typst-plus], Release中有编译完成的可执行文件.

简介

typst简介

Typst 是一款基于标记语言的现代排版系统,用 Rust 编写,旨在成为 LaTeX 的强有力且用户友好的替代品。它采用 Apache-2.0 开源协议,由德国柏林的开发者团队主导开发。

核心定位

  • 现代排版引擎:语法简洁直观,类似 Markdown 与代码的混合体,编译速度极快(秒级甚至毫秒级)
  • 增量编译:基于 comemo 框架实现高效的增量重编译,支持实时预览(typst watch
  • 多格式导出:原生支持 PDF、SVG、PNG、HTML 导出
  • IDE 友好:提供自动补全、悬停提示、跳转定义等完整 IDE 支持

源码结构(17 个核心 Crate)

Typst 采用 Rust Workspace 工作区结构,源码总计约 14 万行 Rust 代码,分布于 374 个源文件中:

编译管线(五阶段增量管线)

.typ 源文件
    ↓ [Parsing: typst-syntax]
Syntax Tree (无类型语法树)
    ↓ [Evaluation: typst-eval]
Content + Module (内容树)
    ↓ [Realization: typst-realize]
Styled Elements (Pairs)
    ↓ [Layout: typst-layout]
Frames (每页一个帧)
    ↓ [Export: typst-pdf / typst-svg / typst-render / typst-html]
PDF / SVG / PNG / HTML

核心 Crate 一览

Crate 职责 代码量
typst 主编译器门面,编排 parse → eval → realize → layout → export 完整管线 ~325 行
typst-syntax 词法分析、语法树、增量重解析、语法高亮 ~5,000 行
typst-eval AST 树遍历解释器、VM、闭包求值、导入系统 ~3,000 行
typst-realize Show 规则应用、样式链、空间折叠、分组规则 ~1,500 行
typst-layout 页面布局、行内文本、网格、数学、流式布局 ~8,000 行
typst-library 标准库:World trait、元素系统、核心类型、样式 ~15,000 行
typst-pdf PDF 导出(通过 krilla 库),支持 PDF/A、PDF/UA 标准 ~2,000 行
typst-svg SVG 导出,含去重优化(字形/渐变/平铺复用) ~1,500 行
typst-render 光栅图像导出(通过 tiny-skia 渲染 PNG) ~500 行
typst-html 实验性 HTML 导出(DOM、CSS、MathML) ~2,000 行
typst-ide IDE 支持:自动补全、跳转定义、悬停提示、诊断 ~4,000 行
typst-cli 命令行接口:compilewatchinitqueryfonts ~1,000 行
typst-kit 工具集:字体发现、文件加载、包管理、HTTP 服务 ~3,000 行
typst-macros 过程宏:#[func]#[elem]#[ty]#[scope] ~1,000 行
typst-utils 共享工具:哈希、位集、切片扩展 ~500 行
typst-timing 性能追踪:编译时间范围测量 ~200 行
typst-bundle 多文档包导出(实验性) ~500 行

关键设计模式

  1. 增量编译(comemo):记忆化 + 依赖追踪 + 缓存验证,贯穿解析、求值、布局全阶段
  2. 动态链接(Routines):函数指针表解决 Crate 间的循环依赖问题
  3. World 抽象World trait 统一封装字体、文件、源文件、日期等系统资源,使编译器可在 CLI、Web、IDE 等不同环境中运行
  4. 内容树与元素系统Content 是中心中间表示,元素通过过程宏(#[elem])定义,支持动态分发
  5. 内省循环(Introspection Loop):布局最多迭代 5 次,直到页码、计数器等自引用信息稳定
  6. 无类型具体语法树(Untyped CST):底层为无类型语法树,上层 ast.rs 提供类型化包装,支持增量重解析时保持 Span 编号稳定

技术亮点

  • 增量重解析:仅重新解析变更区域,保持源码位置(Span)稳定,支撑 typst watch 的实时反馈
  • 记忆化求值:模块和闭包粒度缓存,未变更模块不重新求值
  • 并行布局:使用 rayon 库多线程并行处理独立布局任务
  • SVG 去重:通过 Deduplicator<T> 结构复用渲染后的字形、裁剪路径、渐变,显著减小文件体积
  • 字体管理:系统字体扫描 + 嵌入字体 + 字体回退 + 子集化(PDF 导出时)
  • 包管理:支持本地包与 Typst Universe 远程包(通过 HTTP),带缓存机制

工程

1. 改造typst-render包, 添加jpg等格式导出

typst-render/src/lib.rs

//! Rendering of Typst documents into raster images.

mod image;
mod paint;
mod shape;
mod text;

use tiny_skia as sk;
use typst_layout::{Page, PagedDocument};
use typst_library::layout::{
    Abs, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Size, Transform,
};
use typst_library::visualize::{Color, Geometry, Paint};

/// The image format for raster export.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum RasterFormat {
    /// PNG format.
    Png,
    /// JPEG format.
    Jpeg,
    /// BMP format.
    Bmp,
}

/// Export a page into a raster image.
///
/// This renders the page at the given number of pixels per point and returns
/// the resulting `tiny-skia` pixel buffer.
#[typst_macros::time(name = "render")]
pub fn render(page: &Page, pixel_per_pt: f32) -> sk::Pixmap {
    let size = page.frame.size();
    let pxw = (pixel_per_pt * size.x.to_f32()).round().max(1.0) as u32;
    let pxh = (pixel_per_pt * size.y.to_f32()).round().max(1.0) as u32;

    let ts = sk::Transform::from_scale(pixel_per_pt, pixel_per_pt);
    let state = State::new(size, ts, pixel_per_pt);

    let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();

    if let Some(fill) = page.fill_or_white() {
        if let Paint::Solid(color) = fill {
            canvas.fill(paint::to_sk_color(color));
        } else {
            let rect = Geometry::Rect(page.frame.size()).filled(fill);
            shape::render_shape(&mut canvas, state, &rect);
        }
    }

    render_frame(&mut canvas, state, &page.frame);

    canvas
}

/// Export a document with potentially multiple pages into a single raster image.
pub fn render_merged(
    document: &PagedDocument,
    pixel_per_pt: f32,
    gap: Abs,
    fill: Option<Color>,
) -> sk::Pixmap {
    let pixmaps: Vec<_> = document
        .pages()
        .iter()
        .map(|page| render(page, pixel_per_pt))
        .collect();

    let gap = (pixel_per_pt * gap.to_f32()).round() as u32;
    let pxw = pixmaps.iter().map(sk::Pixmap::width).max().unwrap_or_default();
    let pxh = pixmaps.iter().map(|pixmap| pixmap.height()).sum::<u32>()
        + gap * pixmaps.len().saturating_sub(1) as u32;

    let mut canvas = sk::Pixmap::new(pxw, pxh).unwrap();
    if let Some(fill) = fill {
        canvas.fill(paint::to_sk_color(fill));
    }

    let mut y = 0;
    for pixmap in pixmaps {
        canvas.draw_pixmap(
            0,
            y as i32,
            pixmap.as_ref(),
            &sk::PixmapPaint::default(),
            sk::Transform::identity(),
            None,
        );

        y += pixmap.height() + gap;
    }

    canvas
}

/// Encode a pixmap into the given raster format.
///
/// For JPEG, the alpha channel is ignored (composited on white).
pub fn encode(pixmap: &sk::Pixmap, format: RasterFormat) -> Result<Vec<u8>, String> {
    match format {
        RasterFormat::Png => pixmap
            .encode_png()
            .map_err(|err| format!("failed to encode PNG ({err})")),
        RasterFormat::Jpeg => encode_jpeg(pixmap),
        RasterFormat::Bmp => encode_bmp(pixmap),
    }
}

/// Encode a pixmap as JPEG (quality 95, RGB on white background).
fn encode_jpeg(pixmap: &sk::Pixmap) -> Result<Vec<u8>, String> {
    let width = pixmap.width();
    let height = pixmap.height();
    let rgba = pixmap.data();

    // Convert premultiplied RGBA to non-premultiplied RGB
    let mut rgb = Vec::with_capacity(width as usize * height as usize * 3);
    for chunk in rgba.chunks_exact(4) {
        let a = chunk[3] as f32 / 255.0;
        if a > 0.0 {
            let r = (chunk[0] as f32 / a).min(255.0) as u8;
            let g = (chunk[1] as f32 / a).min(255.0) as u8;
            let b = (chunk[2] as f32 / a).min(255.0) as u8;
            rgb.push(r);
            rgb.push(g);
            rgb.push(b);
        } else {
            rgb.push(255);
            rgb.push(255);
            rgb.push(255);
        }
    }

    let mut buf = Vec::new();
    let mut encoder = ::image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 95);
    encoder
        .encode(&rgb, width, height, ::image::ColorType::Rgb8.into())
        .map_err(|err| format!("failed to encode JPEG ({err})"))?;
    Ok(buf)
}

/// Encode a pixmap as BMP (RGBA).
fn encode_bmp(pixmap: &sk::Pixmap) -> Result<Vec<u8>, String> {
    let width = pixmap.width();
    let height = pixmap.height();
    let rgba = pixmap.data();

    // Convert premultiplied RGBA to non-premultiplied RGBA
    let mut rgba_unpremul = Vec::with_capacity(rgba.len());
    for chunk in rgba.chunks_exact(4) {
        let a = chunk[3] as f32 / 255.0;
        if a > 0.0 {
            rgba_unpremul.push((chunk[0] as f32 / a).min(255.0) as u8);
            rgba_unpremul.push((chunk[1] as f32 / a).min(255.0) as u8);
            rgba_unpremul.push((chunk[2] as f32 / a).min(255.0) as u8);
            rgba_unpremul.push(chunk[3]);
        } else {
            rgba_unpremul.push(0);
            rgba_unpremul.push(0);
            rgba_unpremul.push(0);
            rgba_unpremul.push(0);
        }
    }

    let mut buf = Vec::new();
    let mut encoder = ::image::codecs::bmp::BmpEncoder::new(&mut buf);
    encoder
        .encode(&rgba_unpremul, width, height, ::image::ColorType::Rgba8.into())
        .map_err(|err| format!("failed to encode BMP ({err})"))?;
    Ok(buf)
}

/// Additional metadata carried through the rendering process.
#[derive(Default, Copy, Clone)]
struct State<'a> {
    /// The transform of the current item.
    transform: sk::Transform,
    /// The transform of the first hard frame in the hierarchy.
    container_transform: sk::Transform,
    /// The mask of the current item.
    mask: Option<&'a sk::Mask>,
    /// The pixel per point ratio.
    pixel_per_pt: f32,
    /// The size of the first hard frame in the hierarchy.
    size: Size,
}

impl<'a> State<'a> {
    fn new(size: Size, transform: sk::Transform, pixel_per_pt: f32) -> Self {
        Self {
            size,
            transform,
            container_transform: transform,
            pixel_per_pt,
            ..Default::default()
        }
    }

    /// Pre translate the current item's transform.
    fn pre_translate(self, pos: Point) -> Self {
        Self {
            transform: self.transform.pre_translate(pos.x.to_f32(), pos.y.to_f32()),
            ..self
        }
    }

    fn pre_scale(self, scale: Axes<Abs>) -> Self {
        Self {
            transform: self.transform.pre_scale(scale.x.to_f32(), scale.y.to_f32()),
            ..self
        }
    }

    /// Pre concat the current item's transform.
    fn pre_concat(self, transform: sk::Transform) -> Self {
        Self {
            transform: self.transform.pre_concat(transform),
            ..self
        }
    }

    /// Sets the current mask.
    ///
    /// If no mask is provided, the parent mask is used.
    fn with_mask(self, mask: Option<&'a sk::Mask>) -> State<'a> {
        State { mask: mask.or(self.mask), ..self }
    }

    /// Sets the size of the first hard frame in the hierarchy.
    fn with_size(self, size: Size) -> Self {
        Self { size, ..self }
    }

    /// Pre concat the container's transform.
    fn pre_concat_container(self, transform: sk::Transform) -> Self {
        Self {
            container_transform: self.container_transform.pre_concat(transform),
            ..self
        }
    }
}

/// Render a frame into the canvas.
fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) {
    for (pos, item) in frame.items() {
        match item {
            FrameItem::Group(group) => {
                render_group(canvas, state, *pos, group);
            }
            FrameItem::Text(text) => {
                text::render_text(canvas, state.pre_translate(*pos), text);
            }
            FrameItem::Shape(shape, _) => {
                shape::render_shape(canvas, state.pre_translate(*pos), shape);
            }
            FrameItem::Image(image, size, _) => {
                image::render_image(canvas, state.pre_translate(*pos), image, *size);
            }
            FrameItem::Link(_, _) => {}
            FrameItem::Tag(_) => {}
        }
    }
}

/// Render a group frame with optional transform and clipping into the canvas.
fn render_group(canvas: &mut sk::Pixmap, state: State, pos: Point, group: &GroupItem) {
    let sk_transform = to_sk_transform(&group.transform);
    let state = match group.frame.kind() {
        FrameKind::Soft => state.pre_translate(pos).pre_concat(sk_transform),
        FrameKind::Hard => state
            .pre_translate(pos)
            .pre_concat(sk_transform)
            .pre_concat_container(
                state
                    .transform
                    .post_concat(state.container_transform.invert().unwrap()),
            )
            .pre_concat_container(to_sk_transform(&Transform::translate(pos.x, pos.y)))
            .pre_concat_container(sk_transform)
            .with_size(group.frame.size()),
    };

    let mut mask = state.mask;
    let storage;
    if let Some(clip_curve) = group.clip.as_ref()
        && let Some(path) = shape::convert_curve(clip_curve)
            .and_then(|path| path.transform(state.transform))
    {
        if let Some(mask) = mask {
            let mut mask = mask.clone();
            mask.intersect_path(
                &path,
                sk::FillRule::default(),
                true,
                sk::Transform::default(),
            );
            storage = mask;
        } else {
            let pxw = canvas.width();
            let pxh = canvas.height();
            let Some(mut mask) = sk::Mask::new(pxw, pxh) else {
                // Fails if clipping rect is empty. In that case we just
                // clip everything by returning.
                return;
            };

            mask.fill_path(
                &path,
                sk::FillRule::default(),
                true,
                sk::Transform::default(),
            );
            storage = mask;
        };

        mask = Some(&storage);
    }

    render_frame(canvas, state.with_mask(mask), &group.frame);
}

fn to_sk_transform(transform: &Transform) -> sk::Transform {
    let Transform { sx, ky, kx, sy, tx, ty } = *transform;
    sk::Transform::from_row(
        sx.get() as _,
        ky.get() as _,
        kx.get() as _,
        sy.get() as _,
        tx.to_f32(),
        ty.to_f32(),
    )
}

/// Additional methods for [`Abs`].
trait AbsExt {
    /// Convert to a number of points as f32.
    fn to_f32(self) -> f32;
}

impl AbsExt for Abs {
    fn to_f32(self) -> f32 {
        self.to_pt() as f32
    }
}

2. 添加typst-label包, 支持导出xanylabeling字符级标注

typst-label/Cargo.toml

[package]
name = "typst-label"
description = "Character-level label extraction and visualization for Typst."
version = { workspace = true }
rust-version = { workspace = true }
authors = { workspace = true }
edition = { workspace = true }
homepage = { workspace = true }
repository = { workspace = true }
license = { workspace = true }
categories = { workspace = true }
keywords = { workspace = true }
readme = { workspace = true }

[dependencies]
typst-layout = { workspace = true }
typst-library = { workspace = true }
typst-render = { workspace = true }
typst-macros = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tiny-skia = { workspace = true }
ttf-parser = { workspace = true }

[lints]
workspace = true

typst-label/src/lib.rs

//! Character-level label extraction and visualization for Typst documents.
//!
//! Produces X-AnyLabeling-compatible JSON with `rotation` shape type and
//! embedded base64 `imageData`.

use std::collections::HashMap;

use base64::Engine;
use serde::Serialize;
use tiny_skia as sk;
use typst_layout::Page;
use typst_library::layout::{Abs, Frame, FrameItem, Point, Transform};
use typst_library::text::TextItem;
use typst_render::{RasterFormat, encode, render};

/// A single character-level annotation in X-AnyLabeling format.
#[derive(Debug, Clone, Serialize)]
pub struct LabelShape {
    /// The label text (single character).
    pub label: String,
    /// The shape type (always "rotation" for oriented bounding boxes).
    #[serde(rename = "shape_type")]
    pub shape_type: String,
    /// The oriented bounding box as four corner points [[x1, y1], [x2, y2], [x3, y3], [x4, y4]].
    pub points: Vec<[f64; 2]>,
    /// Rotation direction (0 for no specific direction).
    pub direction: i32,
    /// Additional shape attributes.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shape_attributes: Option<HashMap<String, serde_json::Value>>,
    /// Additional flags.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub flags: Option<HashMap<String, bool>>,
}

/// The root X-AnyLabeling JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct LabelDocument {
    /// Base64-encoded image data (PNG by default).
    #[serde(rename = "imageData")]
    pub image_data: String,
    /// Image height in pixels.
    #[serde(rename = "imageHeight")]
    pub image_height: u32,
    /// Image width in pixels.
    #[serde(rename = "imageWidth")]
    pub image_width: u32,
    /// The path to the image file (empty string when embedding data).
    #[serde(rename = "imagePath")]
    pub image_path: String,
    /// List of labeled shapes.
    pub shapes: Vec<LabelShape>,
    /// Additional flags at document level.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub flags: Option<HashMap<String, bool>>,
    /// Version string.
    pub version: String,
}

/// A character-level bounding box with its text and transform matrix.
#[derive(Debug, Clone)]
pub struct CharBBox {
    /// The character text.
    pub text: String,
    /// The center x coordinate in points.
    pub cx: f64,
    /// The center y coordinate in points.
    pub cy: f64,
    /// The width in points.
    pub width: f64,
    /// The height in points.
    pub height: f64,
    /// The rotation angle in radians.
    pub angle: f64,
}

/// Extract character-level bounding boxes from a page.
pub fn extract_char_bboxes(page: &Page) -> Vec<CharBBox> {
    let mut bboxes = Vec::new();
    let mut stack: Vec<(Point, Transform)> = vec![(Point::zero(), Transform::identity())];
    collect_frame_bboxes(&page.frame, &mut stack, &mut bboxes);
    bboxes
}

fn collect_frame_bboxes(
    frame: &Frame,
    stack: &mut Vec<(Point, Transform)>,
    bboxes: &mut Vec<CharBBox>,
) {
    for (pos, item) in frame.items() {
        match item {
            FrameItem::Text(text) => {
                let (origin, transform) = stack.last().copied().unwrap_or_default();
                let absolute_pos = origin + *pos;
                collect_text_bboxes(text, absolute_pos, transform, bboxes);
            }
            FrameItem::Group(group) => {
                let (origin, transform) = stack.last().copied().unwrap_or_default();
                let new_origin = origin + *pos;
                let new_transform = transform.pre_concat(group.transform);
                stack.push((new_origin, new_transform));
                collect_frame_bboxes(&group.frame, stack, bboxes);
                stack.pop();
            }
            _ => {}
        }
    }
}

fn collect_text_bboxes(
    text: &TextItem,
    pos: Point,
    transform: Transform,
    bboxes: &mut Vec<CharBBox>,
) {
    let size = text.size;
    let mut cursor = Point::zero();

    for glyph in &text.glyphs {
        let advance = Point::new(glyph.x_advance.at(size), glyph.y_advance.at(size));
        let offset = Point::new(glyph.x_offset.at(size), glyph.y_offset.at(size));

        if let Some(rect) = text.font.ttf().glyph_bounding_box(ttf_parser::GlyphId(glyph.id))
        {
            let glyph_pos = cursor + offset;
            let x_min = text.font.to_em(rect.x_min).at(size);
            let y_min = text.font.to_em(rect.y_min).at(size);
            let x_max = text.font.to_em(rect.x_max).at(size);
            let y_max = text.font.to_em(rect.y_max).at(size);

            let min_pt = glyph_pos + Point::new(x_min, -y_max);
            let max_pt = glyph_pos + Point::new(x_max, -y_min);

            let center = Point::new((min_pt.x + max_pt.x) / 2.0, (min_pt.y + max_pt.y) / 2.0);
            let width = max_pt.x - min_pt.x;
            let height = max_pt.y - min_pt.y;

            let abs_center = apply_transform(center + pos, &transform);
            let abs_width = width * transform.sx.get().abs();
            let abs_height = height * transform.sy.get().abs();
            let angle = transform_angle(&transform);

            let char_text = if let Some(slice) = text.text.get(glyph.range()) {
                slice.to_string()
            } else {
                String::new()
            };

            bboxes.push(CharBBox {
                text: char_text,
                cx: abs_center.x.to_pt(),
                cy: abs_center.y.to_pt(),
                width: abs_width.to_pt(),
                height: abs_height.to_pt(),
                angle,
            });
        }

        cursor += advance;
    }
}

fn apply_transform(point: Point, transform: &Transform) -> Point {
    let x = point.x.to_pt();
    let y = point.y.to_pt();
    let tx = transform.sx.get() * x + transform.kx.get() * y + transform.tx.to_pt();
    let ty = transform.ky.get() * x + transform.sy.get() * y + transform.ty.to_pt();
    Point::new(Abs::pt(tx), Abs::pt(ty))
}

fn transform_angle(transform: &Transform) -> f64 {
    let sx = transform.sx.get();
    let ky = transform.ky.get();
    ky.atan2(sx)
}

/// Build an X-AnyLabeling JSON document from a page.
///
/// The `pixel_per_pt` controls the rendered image resolution.
/// The `format` controls the raster image format embedded in `imageData`.
pub fn build_label_document(
    page: &Page,
    pixel_per_pt: f32,
    format: RasterFormat,
) -> Result<LabelDocument, String> {
    let pixmap = render(page, pixel_per_pt);
    let image_data = encode(&pixmap, format)?;
    let image_b64 = base64::engine::general_purpose::STANDARD.encode(&image_data);

    let bboxes = extract_char_bboxes(page);
    let mut shapes = Vec::with_capacity(bboxes.len());

    for bbox in &bboxes {
        let px_cx = bbox.cx * pixel_per_pt as f64;
        let px_cy = bbox.cy * pixel_per_pt as f64;
        let px_w = bbox.width * pixel_per_pt as f64;
        let px_h = bbox.height * pixel_per_pt as f64;

        // Compute four corners of the oriented bounding box.
        let half_w = px_w / 2.0;
        let half_h = px_h / 2.0;
        let cos_a = bbox.angle.cos();
        let sin_a = bbox.angle.sin();
        let corners = [
            (-half_w, -half_h),
            (half_w, -half_h),
            (half_w, half_h),
            (-half_w, half_h),
        ];
        let mut points = Vec::with_capacity(4);
        for (dx, dy) in corners {
            let rx = cos_a * dx - sin_a * dy + px_cx;
            let ry = sin_a * dx + cos_a * dy + px_cy;
            points.push([rx, ry]);
        }

        shapes.push(LabelShape {
            label: bbox.text.clone(),
            shape_type: "rotation".to_string(),
            points,
            direction: 0,
            shape_attributes: None,
            flags: None,
        });
    }

    Ok(LabelDocument {
        image_data: image_b64,
        image_height: pixmap.height(),
        image_width: pixmap.width(),
        image_path: String::new(),
        shapes,
        flags: None,
        version: "0.1.0".to_string(),
    })
}

/// Render a page with bounding box overlays for visualization.
pub fn render_with_bboxes(
    page: &Page,
    pixel_per_pt: f32,
) -> sk::Pixmap {
    let mut pixmap = render(page, pixel_per_pt);
    let bboxes = extract_char_bboxes(page);

    let scale = pixel_per_pt as f64;
    let mut paint = sk::Paint::default();
    paint.set_color_rgba8(255, 0, 0, 180);
    paint.anti_alias = true;

    let stroke = sk::Stroke {
        width: 1.0,
        ..Default::default()
    };

    for bbox in &bboxes {
        let cx = (bbox.cx * scale) as f32;
        let cy = (bbox.cy * scale) as f32;
        let w = (bbox.width * scale) as f32;
        let h = (bbox.height * scale) as f32;
        let angle = bbox.angle as f32;

        let half_w = w / 2.0;
        let half_h = h / 2.0;

        let cos = angle.cos();
        let sin = angle.sin();

        let corners = [
            (-half_w, -half_h),
            (half_w, -half_h),
            (half_w, half_h),
            (-half_w, half_h),
        ];

        let mut path = sk::PathBuilder::new();
        for (i, (dx, dy)) in corners.iter().enumerate() {
            let rx = cos * dx - sin * dy + cx;
            let ry = sin * dx + cos * dy + cy;
            if i == 0 {
                path.move_to(rx, ry);
            } else {
                path.line_to(rx, ry);
            }
        }
        path.close();

        if let Some(path) = path.finish() {
            pixmap.stroke_path(
                &path,
                &paint,
                &stroke,
                sk::Transform::identity(),
                None,
            );
        }
    }

    pixmap
}

3. 构建产物添加时间戳后缀

#!/bin/bash
# Build script that adds timestamp suffix to the compiled binary
set -e

PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$PROJECT_ROOT"

# Get timestamp in yyyyMMddHHmmss format
TIMESTAMP=$(date +%Y%m%d%H%M%S)

echo "Building typst-cli with timestamp suffix: $TIMESTAMP"

# Build release binary
cargo build --release -p typst-cli "$@"

# Determine target directory and binary name
TARGET_DIR="$PROJECT_ROOT/target/release"
BINARY_NAME="typst"

# Platform-specific binary extension
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
    BINARY_EXT=".exe"
else
    BINARY_EXT=""
fi

# Copy binary with timestamp suffix
if [ -f "$TARGET_DIR/${BINARY_NAME}${BINARY_EXT}" ]; then
    cp "$TARGET_DIR/${BINARY_NAME}${BINARY_EXT}" "$TARGET_DIR/${BINARY_NAME}_${TIMESTAMP}${BINARY_EXT}"
    echo "Binary copied to: $TARGET_DIR/${BINARY_NAME}_${TIMESTAMP}${BINARY_EXT}"
fi

# Also handle cross-compilation targets
for target_dir in "$PROJECT_ROOT/target"/*/release; do
    if [ -f "$target_dir/${BINARY_NAME}${BINARY_EXT}" ]; then
        cp "$target_dir/${BINARY_NAME}${BINARY_EXT}" "$target_dir/${BINARY_NAME}_${TIMESTAMP}${BINARY_EXT}"
        echo "Binary copied to: $target_dir/${BINARY_NAME}_${TIMESTAMP}${BINARY_EXT}"
    fi
done

echo "Build complete!"

4. 顶层cli添加参数

typst-cli/src/compile.rs

use std::ffi::OsStr;
use std::path::Path;

use chrono::{DateTime, Datelike, Timelike, Utc};
use ecow::eco_format;
use parking_lot::RwLock;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use typst::diag::{
    At, HintedStrResult, HintedString, SourceDiagnostic, SourceResult, StrResult, Warned,
    bail,
};
use typst::foundations::{Datetime, Smart};
use typst::layout::PageRanges;
use typst::syntax::Span;
use typst_bundle::{Bundle, BundleOptions, VirtualFs};
use typst_html::HtmlDocument;
use typst_kit::timer::Timer;
use typst_layout::{Page, PagedDocument};
use typst_pdf::{PdfOptions, PdfStandards, Timestamp};

use crate::args::{
    CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output,
    OutputFormat, PdfStandard, WatchCommand,
};
use crate::deps::write_deps;
use crate::watch::Status;
use crate::world::SystemWorld;
use crate::{set_failed, terminal};

#[cfg(feature = "http-server")]
use typst_kit::server::HttpServer;

/// Execute a compilation command.
pub fn compile(command: &'static CompileCommand) -> HintedStrResult<()> {
    let mut timer = Timer::new_or_placeholder(command.args.timings.clone());
    let mut config = CompileConfig::new(command)?;
    let mut world = SystemWorld::new(
        Some(&command.args.input),
        &command.args.world,
        &command.args.process,
    )
    .map_err(|err| eco_format!("{err}"))?;
    timer.record(&mut world, |world| compile_once(world, &mut config))?
}

/// A preprocessed `CompileCommand`.
pub struct CompileConfig {
    /// Static warnings to emit after compilation.
    pub warnings: Vec<HintedString>,
    /// Whether we are watching.
    pub watching: bool,
    /// Path to input Typst file or stdin.
    pub input: Input,
    /// Path to output file (PDF, PNG, SVG, or HTML).
    pub output: Output,
    /// The format of the output file.
    pub output_format: OutputFormat,
    /// Which pages to export.
    pub pages: Option<PageRanges>,
    /// The document's creation date formatted as a UNIX timestamp, with UTC suffix.
    pub creation_timestamp: Option<DateTime<Utc>>,
    /// The format to emit diagnostics in.
    pub diagnostic_format: DiagnosticFormat,
    /// Opens the output file with the default viewer or a specific program after
    /// compilation.
    pub open: Option<Option<String>>,
    /// A list of standards the PDF should conform to.
    pub pdf_standards: PdfStandards,
    /// Whether to write PDF (accessibility) tags.
    pub tagged: bool,
    /// A destination to write a list of dependencies to.
    pub deps: Option<Output>,
    /// The format to use for dependencies.
    pub deps_format: DepsFormat,
    /// The PPI (pixels per inch) to use for raster image export.
    pub ppi: f32,
    /// Whether to export character-level label JSON.
    pub label_json: bool,
    /// Whether to export a visualization image with bounding boxes.
    pub label_viz: bool,
    /// The export cache for images, used for caching output files in `typst
    /// watch` sessions with images.
    pub export_cache: ExportCache,
    /// Server for `typst watch` to HTML.
    #[cfg(feature = "http-server")]
    pub server: Option<HttpServer>,
}

impl CompileConfig {
    /// Preprocess a `CompileCommand`, producing a compilation config.
    pub fn new(command: &CompileCommand) -> HintedStrResult<Self> {
        Self::new_impl(&command.args, None)
    }

    /// Preprocess a `WatchCommand`, producing a compilation config.
    pub fn watching(command: &WatchCommand) -> HintedStrResult<Self> {
        Self::new_impl(&command.args, Some(command))
    }

    /// The shared implementation of [`CompileConfig::new`] and
    /// [`CompileConfig::watching`].
    fn new_impl(
        args: &CompileArgs,
        watch: Option<&WatchCommand>,
    ) -> HintedStrResult<Self> {
        let mut warnings = Vec::new();
        let input = args.input.clone();

        let output_format = if let Some(specified) = args.format {
            specified
        } else if let Some(Output::Path(output)) = &args.output {
            match output.extension() {
                Some(ext) if ext.eq_ignore_ascii_case("pdf") => OutputFormat::Pdf,
                Some(ext) if ext.eq_ignore_ascii_case("png") => OutputFormat::Png,
                Some(ext) if ext.eq_ignore_ascii_case("jpg") | ext.eq_ignore_ascii_case("jpeg") => OutputFormat::Jpeg,
                Some(ext) if ext.eq_ignore_ascii_case("bmp") => OutputFormat::Bmp,
                Some(ext) if ext.eq_ignore_ascii_case("svg") => OutputFormat::Svg,
                Some(ext) if ext.eq_ignore_ascii_case("html") => OutputFormat::Html,
                _ => bail!(
                    "could not infer output format for path {}.\n\
                     consider providing the format manually with `--format/-f`",
                    output.display(),
                ),
            }
        } else {
            OutputFormat::Pdf
        };

        let output = args.output.clone().unwrap_or_else(|| {
            let Input::Path(path) = &input else {
                panic!("output must be specified when input is from stdin, as guarded by the CLI");
            };
            Output::Path(path.with_extension(
                match output_format {
                    OutputFormat::Pdf => "pdf",
                    OutputFormat::Png => "png",
                    OutputFormat::Jpeg => "jpg",
                    OutputFormat::Bmp => "bmp",
                    OutputFormat::Svg => "svg",
                    OutputFormat::Html => "html",
                    OutputFormat::Bundle => "",
                },
            ))
        });

        let pages = args.pages.as_ref().map(|export_ranges| {
            PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect())
        });

        let tagged = !args.no_pdf_tags && pages.is_none();
        if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags {
            warnings.push(
                HintedString::from("using --pages implies --no-pdf-tags").with_hints([
                    "the resulting PDF will be inaccessible".into(),
                    "add --no-pdf-tags to silence this warning".into(),
                ]),
            );
        }

        if !tagged {
            const ACCESSIBLE: &[(PdfStandard, &str)] = &[
                (PdfStandard::A_1a, "PDF/A-1a"),
                (PdfStandard::A_2a, "PDF/A-2a"),
                (PdfStandard::A_3a, "PDF/A-3a"),
                (PdfStandard::UA_1, "PDF/UA-1"),
            ];

            for (standard, name) in ACCESSIBLE {
                if args.pdf_standard.contains(standard) {
                    if args.no_pdf_tags {
                        bail!("cannot disable PDF tags when exporting a {name} document");
                    } else {
                        bail!(
                            "cannot disable PDF tags when exporting a {name} document";
                            hint: "using --pages implies --no-pdf-tags";
                        );
                    }
                }
            }
        }

        let pdf_standards = PdfStandards::new(
            &args.pdf_standard.iter().copied().map(Into::into).collect::<Vec<_>>(),
        )?;

        #[cfg(feature = "http-server")]
        let server = if let Some(command) = watch
            && !command.server.no_serve
            && matches!(output_format, OutputFormat::Html | OutputFormat::Bundle)
        {
            Some(HttpServer::new(
                &eco_format!("{input}"),
                command.server.port,
                !command.server.no_reload,
            )?)
        } else {
            None
        };

        let mut deps = args.deps.clone();
        let mut deps_format = args.deps_format;

        if let Some(path) = &args.make_deps
            && deps.is_none()
        {
            deps = Some(Output::Path(path.clone()));
            deps_format = DepsFormat::Make;
            warnings.push(
                "--make-deps is deprecated, use --deps and --deps-format instead".into(),
            );
        }

        match (&output, &deps, watch) {
            (Output::Stdout, _, Some(_)) => {
                bail!("cannot write document to stdout in watch mode");
            }
            (_, Some(Output::Stdout), Some(_)) => {
                bail!("cannot write dependencies to stdout in watch mode")
            }
            (Output::Stdout, Some(Output::Stdout), _) => {
                bail!("cannot write both output and dependencies to stdout")
            }
            _ => {}
        }

        Ok(Self {
            warnings,
            watching: watch.is_some(),
            input,
            output,
            output_format,
            pages,
            pdf_standards,
            tagged,
            creation_timestamp: args
                .world
                .creation_timestamp
                .map(|time| {
                    chrono::DateTime::from_timestamp(time, 0)
                        .ok_or("creation timestamp is out of range")
                })
                .transpose()?,
            ppi: if args.dpi != 144.0 { args.dpi } else { args.ppi },
            label_json: args.label_json,
            label_viz: args.label_viz,
            diagnostic_format: args.process.diagnostic_format,
            open: args.open.clone(),
            export_cache: ExportCache::new(),
            deps,
            deps_format,
            #[cfg(feature = "http-server")]
            server,
        })
    }
}

/// Compile a single time.
///
/// Returns whether it compiled without errors.
#[typst_macros::time(name = "compile once")]
pub fn compile_once(
    world: &mut SystemWorld,
    config: &mut CompileConfig,
) -> HintedStrResult<()> {
    let start = std::time::Instant::now();
    if config.watching {
        Status::Compiling.print(config).unwrap();
    }

    let Warned { output, mut warnings } = compile_and_export(world, config);

    // Add static warnings (for deprecated CLI flags and such).
    for warning in config.warnings.iter() {
        warnings.push(
            SourceDiagnostic::warning(Span::detached(), warning.message())
                .with_hints(warning.hints().iter().map(Into::into)),
        );
    }

    match &output {
        // Print success message and possibly warnings.
        Ok(_) => {
            let duration = start.elapsed();
            if config.watching {
                if warnings.is_empty() {
                    Status::Success(duration).print(config).unwrap();
                } else {
                    Status::PartialSuccess(duration).print(config).unwrap();
                }
            }

            print_diagnostics(world, &[], &warnings, config.diagnostic_format)
                .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;

            open_output(config)?;
        }

        // Print failure message and diagnostics.
        Err(errors) => {
            set_failed();

            if config.watching {
                Status::Error.print(config).unwrap();
            }

            print_diagnostics(world, errors, &warnings, config.diagnostic_format)
                .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
        }
    }

    if let Some(dest) = &config.deps {
        write_deps(world, dest, config.deps_format, output.as_deref().ok())
            .map_err(|err| eco_format!("failed to create dependency file ({err})"))?;
    }

    Ok(())
}

/// Compile and then export the document.
fn compile_and_export(
    world: &mut SystemWorld,
    config: &mut CompileConfig,
) -> Warned<SourceResult<Vec<Output>>> {
    match config.output_format {
        OutputFormat::Pdf | OutputFormat::Png | OutputFormat::Jpeg | OutputFormat::Bmp | OutputFormat::Svg => {
            let Warned { output, warnings } = typst::compile::<PagedDocument>(world);
            let result = output.and_then(|document| export_paged(&document, config));
            Warned { output: result, warnings }
        }
        OutputFormat::Html => {
            let Warned { output, warnings } = typst::compile::<HtmlDocument>(world);
            let result = output.and_then(|document| export_html(&document, config));
            Warned {
                output: result.map(|()| vec![config.output.clone()]),
                warnings,
            }
        }
        OutputFormat::Bundle => {
            let Warned { output, warnings } = typst::compile::<Bundle>(world);
            let result = output.and_then(|bundle| export_bundle(bundle, config));
            Warned { output: result, warnings }
        }
    }
}

/// Export to HTML.
fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> {
    let html = typst_html::html(document)?;
    let result = config.output.write(html.as_bytes());

    #[cfg(feature = "http-server")]
    if let Some(server) = &config.server {
        server.set_html(html);
    }

    result
        .map_err(|err| eco_format!("failed to write HTML file ({err})"))
        .at(Span::detached())
}

/// Export to a paged target format.
fn export_paged(
    document: &PagedDocument,
    config: &CompileConfig,
) -> SourceResult<Vec<Output>> {
    match config.output_format {
        OutputFormat::Pdf => {
            export_pdf(document, config).map(|()| vec![config.output.clone()])
        }
        OutputFormat::Png => {
            export_image(document, config, ImageExportFormat::Raster(typst_render::RasterFormat::Png)).at(Span::detached())
        }
        OutputFormat::Jpeg => {
            export_image(document, config, ImageExportFormat::Raster(typst_render::RasterFormat::Jpeg)).at(Span::detached())
        }
        OutputFormat::Bmp => {
            export_image(document, config, ImageExportFormat::Raster(typst_render::RasterFormat::Bmp)).at(Span::detached())
        }
        OutputFormat::Svg => {
            export_image(document, config, ImageExportFormat::Svg).at(Span::detached())
        }
        OutputFormat::Html | OutputFormat::Bundle => unreachable!(),
    }
}

/// Export to a PDF.
fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> {
    let options = pdf_options(config);
    let buffer = typst_pdf::pdf(document, &options)?;
    config
        .output
        .write(&buffer)
        .map_err(|err| eco_format!("failed to write PDF file ({err})"))
        .at(Span::detached())?;
    Ok(())
}

/// Creates options for PDF export.
fn pdf_options(config: &CompileConfig) -> PdfOptions<'static> {
    // If the timestamp is provided through the CLI, use UTC suffix,
    // else, use the current local time and timezone.
    let timestamp = match config.creation_timestamp {
        Some(timestamp) => convert_datetime(timestamp).map(Timestamp::new_utc),
        None => {
            let local_datetime = chrono::Local::now();
            convert_datetime(local_datetime).and_then(|datetime| {
                Timestamp::new_local(
                    datetime,
                    local_datetime.offset().local_minus_utc() / 60,
                )
            })
        }
    };

    PdfOptions {
        ident: Smart::Auto,
        timestamp,
        page_ranges: config.pages.clone(),
        standards: config.pdf_standards.clone(),
        tagged: config.tagged,
    }
}

/// Export to a bundle, a collection of files in a directory.
fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult<Vec<Output>> {
    let options = BundleOptions {
        pixel_per_pt: config.ppi / 72.0,
        pdf: pdf_options(config),
    };

    let fs = typst_bundle::export(&bundle, &options)?;
    let root = match &config.output {
        Output::Path(path) => path,
        Output::Stdout => {
            bail!(Span::detached(), "cannot write bundle to standard output")
        }
    };

    let outputs = write_virtual_fs(root, &fs).at(Span::detached())?;

    #[cfg(feature = "http-server")]
    if let Some(server) = &config.server {
        server.set_bundle(bundle, fs);
    }

    Ok(outputs)
}

/// Writes a bundle's files to disk.
fn write_virtual_fs(root: &Path, fs: &VirtualFs) -> StrResult<Vec<Output>> {
    std::fs::create_dir_all(root)
        .map_err(|err| eco_format!("failed to create output directory ({err})"))?;

    fs.par_iter()
        .map(|(path, data)| {
            let realized = path.realize(root);

            if let Some(parent) = realized.parent() {
                std::fs::create_dir_all(parent)
                    .map_err(|err| eco_format!("failed to create directory ({err})"))?;
            }

            std::fs::write(&realized, data)
                .map_err(|err| eco_format!("failed to write file ({err})"))?;
            Ok(Output::Path(realized))
        })
        .collect()
}

/// Convert [`chrono::DateTime`] to [`Datetime`]
fn convert_datetime<Tz: chrono::TimeZone>(
    date_time: chrono::DateTime<Tz>,
) -> Option<Datetime> {
    Datetime::from_ymd_hms(
        date_time.year(),
        date_time.month().try_into().ok()?,
        date_time.day().try_into().ok()?,
        date_time.hour().try_into().ok()?,
        date_time.minute().try_into().ok()?,
        date_time.second().try_into().ok()?,
    )
}

/// An image format to export in.
#[derive(Copy, Clone)]
enum ImageExportFormat {
    Raster(typst_render::RasterFormat),
    Svg,
}

/// Export to one or multiple images.
fn export_image(
    document: &PagedDocument,
    config: &CompileConfig,
    fmt: ImageExportFormat,
) -> StrResult<Vec<Output>> {
    // Determine whether we have indexable templates in output
    let can_handle_multiple = match config.output {
        Output::Stdout => false,
        Output::Path(ref output) => {
            output_template::has_indexable_template(output.to_str().unwrap_or_default())
        }
    };

    let exported_pages = document
        .pages()
        .iter()
        .enumerate()
        .filter(|(i, _)| {
            config.pages.as_ref().is_none_or(|exported_page_ranges| {
                exported_page_ranges.includes_page_index(*i)
            })
        })
        .collect::<Vec<_>>();

    if !can_handle_multiple && exported_pages.len() > 1 {
        let err = match config.output {
            Output::Stdout => "to stdout",
            Output::Path(_) => {
                "without a page number template ({p}, {0p}) in the output path"
            }
        };
        bail!("cannot export multiple images {err}");
    }

    // The results are collected in a `Vec<()>` which does not allocate.
    exported_pages
        .par_iter()
        .map(|(i, page)| {
            // Use output with converted path.
            let output = match &config.output {
                Output::Path(path) => {
                    let storage;
                    let path = if can_handle_multiple {
                        storage = output_template::format(
                            path.to_str().unwrap_or_default(),
                            i + 1,
                            document.pages().len(),
                        );
                        Path::new(&storage)
                    } else {
                        path
                    };

                    // If we are not watching, don't use the cache.
                    // If the frame is in the cache, skip it.
                    // If the file does not exist, always create it.
                    if config.watching
                        && config.export_cache.is_cached(*i, page)
                        && path.exists()
                    {
                        return Ok(Output::Path(path.to_path_buf()));
                    }

                    Output::Path(path.to_owned())
                }
                Output::Stdout => Output::Stdout,
            };

            export_image_page(config, page, &output, fmt)?;
            Ok(output)
        })
        .collect::<StrResult<Vec<Output>>>()
}

mod output_template {
    const INDEXABLE: [&str; 3] = ["{p}", "{0p}", "{n}"];

    pub fn has_indexable_template(output: &str) -> bool {
        INDEXABLE.iter().any(|template| output.contains(template))
    }

    pub fn format(output: &str, this_page: usize, total_pages: usize) -> String {
        // Find the base 10 width of number `i`
        fn width(i: usize) -> usize {
            1 + i.checked_ilog10().unwrap_or(0) as usize
        }

        let other_templates = ["{t}"];
        INDEXABLE.iter().chain(other_templates.iter()).fold(
            output.to_string(),
            |out, template| {
                let replacement = match *template {
                    "{p}" => format!("{this_page}"),
                    "{0p}" | "{n}" => format!("{:01$}", this_page, width(total_pages)),
                    "{t}" => format!("{total_pages}"),
                    _ => unreachable!("unhandled template placeholder {template}"),
                };
                out.replace(template, replacement.as_str())
            },
        )
    }
}

/// Export single image.
fn export_image_page(
    config: &CompileConfig,
    page: &Page,
    output: &Output,
    fmt: ImageExportFormat,
) -> StrResult<()> {
    match fmt {
        ImageExportFormat::Raster(format) => {
            let pixmap = typst_render::render(page, config.ppi / 72.0);
            let buf = typst_render::encode(&pixmap, format)
                .map_err(|err| eco_format!("failed to encode image file ({err})"))?;
            output
                .write(&buf)
                .map_err(|err| eco_format!("failed to write image file ({err})"))?;

            // Export label JSON if requested.
            if config.label_json {
                if let Output::Path(path) = output {
                    let label_doc = typst_label::build_label_document(
                        page,
                        config.ppi / 72.0,
                        format,
                    )
                    .map_err(|err| eco_format!("failed to build label document ({err})"))?;
                    let json = serde_json::to_string_pretty(&label_doc)
                        .map_err(|err| eco_format!("failed to serialize label JSON ({err})"))?;
                    let label_path = path.with_extension("json");
                    std::fs::write(&label_path, json)
                        .map_err(|err| eco_format!("failed to write label JSON ({err})"))?;
                }
            }

            // Export visualization image if requested.
            if config.label_viz {
                if let Output::Path(path) = output {
                    let viz_pixmap = typst_label::render_with_bboxes(page, config.ppi / 72.0);
                    let viz_buf = typst_render::encode(&viz_pixmap, format)
                        .map_err(|err| eco_format!("failed to encode viz image ({err})"))?;
                    let viz_path = path.with_extension(format!("viz.{}", path.extension().and_then(|s| s.to_str()).unwrap_or("png")));
                    std::fs::write(&viz_path, viz_buf)
                        .map_err(|err| eco_format!("failed to write viz image ({err})"))?;
                }
            }
        }
        ImageExportFormat::Svg => {
            let svg = typst_svg::svg(page);
            output
                .write(svg.as_bytes())
                .map_err(|err| eco_format!("failed to write SVG file ({err})"))?;
        }
    }
    Ok(())
}

/// Caches exported files so that we can avoid re-exporting them if they haven't
/// changed.
///
/// This is done by having a list of size `files.len()` that contains the hashes
/// of the last rendered frame in each file. If a new frame is inserted, this
/// will invalidate the rest of the cache, this is deliberate as to decrease the
/// complexity and memory usage of such a cache.
pub struct ExportCache {
    /// The hashes of last compilation's frames.
    pub cache: RwLock<Vec<u128>>,
}

impl ExportCache {
    /// Creates a new export cache.
    pub fn new() -> Self {
        Self { cache: RwLock::new(Vec::with_capacity(32)) }
    }

    /// Returns true if the entry is cached and appends the new hash to the
    /// cache (for the next compilation).
    pub fn is_cached(&self, i: usize, page: &Page) -> bool {
        let hash = typst::utils::hash128(page);

        let mut cache = self.cache.upgradable_read();
        if i >= cache.len() {
            cache.with_upgraded(|cache| cache.push(hash));
            return false;
        }

        cache.with_upgraded(|cache| std::mem::replace(&mut cache[i], hash) == hash)
    }
}

/// Opens the output if desired.
fn open_output(config: &mut CompileConfig) -> StrResult<()> {
    let Some(viewer) = config.open.take() else { return Ok(()) };

    #[cfg(feature = "http-server")]
    if let Some(server) = &config.server {
        let url = format!("http://{}", server.addr());
        return open_path(OsStr::new(&url), viewer.as_deref());
    }

    // Can't open stdout.
    let Output::Path(path) = &config.output else { return Ok(()) };

    // Some resource openers require the path to be canonicalized.
    let path = path
        .canonicalize()
        .map_err(|err| eco_format!("failed to canonicalize path ({err})"))?;

    open_path(path.as_os_str(), viewer.as_deref())
}

/// Opens the given file using:
///
/// - The default file viewer if `app` is `None`.
/// - The given viewer provided by `app` if it is `Some`.
fn open_path(path: &OsStr, viewer: Option<&str>) -> StrResult<()> {
    if let Some(viewer) = viewer {
        open::with_detached(path, viewer)
            .map_err(|err| eco_format!("failed to open file with {} ({})", viewer, err))
    } else {
        open::that_detached(path).map_err(|err| {
            let openers = open::commands(path)
                .iter()
                .map(|command| command.get_program().to_string_lossy())
                .collect::<Vec<_>>()
                .join(", ");
            eco_format!(
                "failed to open file with any of these resource openers: {} ({})",
                openers,
                err,
            )
        })
    }
}

/// Print diagnostic messages to the terminal.
pub fn print_diagnostics(
    world: &SystemWorld,
    errors: &[SourceDiagnostic],
    warnings: &[SourceDiagnostic],
    format: DiagnosticFormat,
) -> Result<(), codespan_reporting::files::Error> {
    typst_kit::diagnostics::emit(
        &mut terminal::out(),
        world,
        errors.iter().chain(warnings),
        match format {
            DiagnosticFormat::Human => typst_kit::diagnostics::DiagnosticFormat::Human,
            DiagnosticFormat::Short => typst_kit::diagnostics::DiagnosticFormat::Short,
        },
    )
}

impl From<PdfStandard> for typst_pdf::PdfStandard {
    fn from(standard: PdfStandard) -> Self {
        match standard {
            PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4,
            PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5,
            PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6,
            PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7,
            PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0,
            PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b,
            PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a,
            PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b,
            PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u,
            PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a,
            PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b,
            PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u,
            PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a,
            PdfStandard::A_4 => typst_pdf::PdfStandard::A_4,
            PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f,
            PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e,
            PdfStandard::UA_1 => typst_pdf::PdfStandard::Ua_1,
        }
    }
}

效果

#set page(width: 3644pt, height: 457pt, margin: 30pt, fill: rgb(255, 255, 255))
#set text(font: "SimSun", size: 82pt, fill: rgb(0, 0, 0))
#set par(justify: true, leading: 0.5em)
#let indent = h(2em)

// 为首段单独设置首行缩进
#indent 摸鱼摸鱼汽车有限公司声明:本清单为本企业依据《中华人民共和国大气污染防治法》和生态环境部相关规定公开的机动车环保信息,本企业对本清单所有内容的真实性、准确性、及时性和完整性负责。本企业承诺: VIN 码(见封面条形码)的纯电动汽车符合《汽车加速行驶车外噪声限值及测量方法》(GB 1495)的相关要求。

生成的字符级标注效果图片
generated
posted @ 2026-05-21 11:41  qsBye  阅读(5)  评论(0)    收藏  举报