Registry / RepositoryTypeScriptRustPython
Rollpie
ReadmeFiles
Versions
Info
Download
0.2.0579.9 KB2026-09-150.1.1576.6 KB2026-09-12
Version
0.2.0
Copyright
Rollpie, Irohabook
Publisher
math
Published
2026-09-15
Size
579.9 KB
Downloads
2
Checksum
58312d92e63a92784e82a974317afd849e59d143a6320c3cc62faa0980834a7b
Dependencies
None

html.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
//! 位置と寸法の決まったまとまりを HTML にする
//!
//! 文字はすべて絶対配置で置く。縦の位置は、高さだけを持つ支柱 (strut) を先に置いて
//! 行のベースラインを決めることで、フォントの上下寸法に頼らずに決めている。
//!
//! ここが出すのは文字であって輪郭ではない。表示する側に同じフォントが要るかわりに、
//! 選んで写せる。輪郭で出したいときは svg.rs を使う。

use crate::font::{self, Font};
use crate::layout::{Content, Layout};
use crate::node::class_name;
use crate::outline;
use crate::svg::DARKEN;
use crate::text::{escape, number, spacing_mark};


/// 支柱を中身より確実に高くするための上乗せ
const STRUT_MARGIN: f64 = 1.0;


/// 出力した HTML を表示するのに要る CSS。書体の名前はフォントから取る
pub fn style(font: &Font) -> String {
    format!(
        concat!(
            ".suki{{display:inline-block;position:relative;text-align:left;line-height:1;",
            "white-space:pre;font-family:\"{}\",{};font-style:normal;font-weight:normal}}\n",
            ".suki .at{{position:absolute;height:0}}\n",
            ".suki .strut{{display:inline-block;width:0;overflow:hidden}}\n",
            ".suki .rule{{display:inline-block;background:currentColor}}\n",
            ".suki .drawn{{display:inline-block;overflow:visible;fill:currentColor}}\n",
            ".suki .stretch{{display:inline-block;transform-origin:0 84%}}\n"
        ),
        escape(font::family(font)),
        font::FALLBACK
    )
}


/// まとまりを HTML にする。結果は 1 つの `<span class="suki">`
pub fn render(placed: &Layout, font: &Font) -> String {
    let mut out = String::new();
    write_box(placed, font, &mut out);

    out
}


fn write_box(placed: &Layout, font: &Font, out: &mut String) {
    out.push_str(&format!(
        "<span class=\"suki\" style=\"width:{}em;height:{}em;vertical-align:{}em\">",
        number(placed.width),
        number(placed.height + placed.depth),
        number(-placed.depth)
    ));

    match &placed.content {
        Content::Group(children) => {
            for child in children {
                write_child(child.x, child.y, &child.layout, font, placed.height, out);
            }
        }
        _ => write_child(0.0, 0.0, placed, font, placed.height, out),
    }

    out.push_str("</span>");
}


/// 親の中の 1 つを、支柱でベースラインを決めてから置く
fn write_child(x: f64, y: f64, placed: &Layout, font: &Font, parent_height: f64, out: &mut String) {
    if is_blank(placed) {
        return;
    }

    out.push_str(&format!(
        concat!(
            "<span class=\"at\" style=\"left:{}em;top:{}em\">",
            "<span class=\"strut\" style=\"height:{}em\"></span>"
        ),
        number(x),
        number(-STRUT_MARGIN),
        number(parent_height + y + STRUT_MARGIN)
    ));
    write_inner(placed, font, out);
    out.push_str("</span>");
}


fn write_inner(placed: &Layout, font: &Font, out: &mut String) {
    match &placed.content {
        Content::Glyph {text, glyph, size, ..} if text.is_empty() => {
            write_drawn(placed, font, *glyph, *size, out);
        }
        Content::Glyph {text, class, size, italic, bold, stretch, ..} => {
            let shown: String = text.chars().map(spacing_mark).collect();
            let mut names = format!("glyph {}", class_name(*class));
            let mut inline = format!("font-size:{}em", number(*size));

            if *italic {
                inline.push_str(";font-style:italic");
            }

            if *bold {
                inline.push_str(";font-weight:bold");
            }

            if *stretch != 1.0 {
                names.push_str(" stretch");
                inline.push_str(&format!(";transform:scaleY({})", number(*stretch)));
            }

            out.push_str(&format!(
                "<span class=\"{names}\" style=\"{inline}\">{}</span>",
                escape(&shown)
            ));
        }
        Content::Rule => {
            out.push_str(&format!(
                "<span class=\"rule\" style=\"width:{}em;height:{}em\"></span>",
                number(placed.width),
                number(placed.height + placed.depth)
            ));
        }
        Content::Group(_) => write_box(placed, font, out),
    }
}


/// 文字の割り当てがない字形を、輪郭を持つ小さな SVG として置く
///
/// 伸ばした括弧や大きな演算子の字形には文字が割り当てられていないので、
/// span に文字を入れる形では書き出せない。ここだけ輪郭で描く
fn write_drawn(placed: &Layout, font: &Font, glyph: u16, size: f64, out: &mut String) {
    let drawn = font::outline_of(font, glyph);

    if drawn.segment.is_empty() {
        return;
    }

    let scale = size / font::units_per_em(font);
    let width = placed.width.max(0.01);
    let height = (placed.height + placed.depth).max(0.01);

    out.push_str(&format!(
        concat!(
            "<svg class=\"drawn\" xmlns=\"http://www.w3.org/2000/svg\"",
            " width=\"{}em\" height=\"{}em\" viewBox=\"0 {} {} {}\"",
            " style=\"vertical-align:{}em\">",
            "<path d=\"{}\" stroke=\"currentColor\" stroke-width=\"{}\"",
            " stroke-linejoin=\"round\"/></svg>"
        ),
        number(width),
        number(height),
        number(-placed.height),
        number(width),
        number(height),
        number(-placed.depth),
        outline::path_data(&drawn, scale, 0.0, 0.0),
        number(DARKEN)
    ));
}


/// 描くものがないまとまり
fn is_blank(placed: &Layout) -> bool {
    match &placed.content {
        Content::Glyph {text, glyph, ..} => text.is_empty() && *glyph == 0,
        Content::Group(children) => children.is_empty(),
        Content::Rule => placed.width <= 0.0 || placed.height + placed.depth <= 0.0,
    }
}


#[cfg(test)]
mod tests {
    use super::*;
    use crate::layout::layout;
    use crate::node::Style;
    use crate::parse::parse;

    fn sample() -> Font {
        let data = std::fs::read("font/latinmodern-math.otf").expect("試験用のフォントを読めません");

        font::load(data).unwrap()
    }

    fn html(source: &str, display: bool) -> String {
        let font = sample();
        let placed = layout(&parse(source).unwrap(), &font, 1.0, Style::Auto, display);

        render(&placed, &font)
    }

    #[test]
    fn a_variable_uses_the_math_italic_shape() {
        let out = html("x", false);
        assert!(out.contains("\u{1d465}"), "{out}");
        assert!(!out.contains("font-style:italic"));
    }

    #[test]
    fn a_digit_stays_upright() {
        let out = html("2", false);
        assert!(out.contains(">2</span>"));
        assert!(!out.contains("font-style:italic"));
    }

    #[test]
    fn every_child_carries_a_strut() {
        let out = html("x+1", false);
        assert_eq!(out.matches("class=\"strut\"").count(), out.matches("class=\"at\"").count());
    }

    #[test]
    fn a_fraction_draws_a_rule() {
        assert!(html(r"\frac{a}{b}", false).contains("class=\"rule\""));
        assert!(!html(r"\binom{n}{k}", false).contains("class=\"rule\""));
    }

    #[test]
    fn tall_delimiters_are_drawn_as_outlines() {
        // 大きい字形には文字の割り当てがないので、ここだけ輪郭になる
        let out = html(r"\left( \frac{a}{b} \right)", false);
        assert_eq!(out.matches("class=\"drawn\"").count(), 2, "{out}");
    }

    #[test]
    fn a_plain_paren_stays_a_character() {
        let out = html(r"\left( x \right)", false);
        assert!(!out.contains("class=\"drawn\""));
        assert!(out.contains(">(</span>"));
    }

    #[test]
    fn markup_in_the_source_is_escaped() {
        assert!(html("a<b", false).contains("&lt;"));
        assert!(html(r"\text{a & b}", false).contains("&amp;"));
    }

    #[test]
    fn the_style_names_the_font() {
        assert!(style(&sample()).contains("\"Latin Modern Math\""));
    }
}