Skip to main content

atmos/os_lib/web_engine/
iframe_preview.rs

1//! `<iframe>` の代替表示(純粋モジュール): 埋め込み先 HTML の OGP からタイトル・説明・画像を読む。
2//!
3//! 【2026-10-03】AtmOS は入れ子のブラウジングコンテキスト(iframe の中で別ページを動かすこと)を
4//! まだ持たない。sugi-lab.net/smarthome.htm の Microsoft Sway 埋め込みは大規模な JS アプリで、
5//! 中身を実行しても表示できない。一方、埋め込みページの `<head>` には OGP
6//! (`og:title`・`og:description`・`og:image`)があり、Chrome で Sway が最初に見せる画面
7//! (タイトルと説明文)と同じ内容である。そこで iframe の中身の HTML を取得し、ここで
8//! OGP を読んで「タイトルと説明文のカード」として枠内に描く(地図の `osm_embed` と同じ考え方の、
9//! iframe 一般に効く縮退表示)。
10//!
11//! 属性値は引用符あり・なし(Sway は `content=Website` のように省略する)の両方を受け付け、
12//! 文字参照(`&amp;` 等)は戻す。OGP が無ければ `<title>` と `<meta name=description>` を使う。
13
14extern crate alloc;
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// 代替表示に使う情報。
19#[derive(Debug, Clone, PartialEq, Default)]
20pub struct Preview {
21    pub title: String,
22    pub description: String,
23    /// 画像 URL(文字参照は戻し済み、相対 URL はそのまま)。
24    pub image: String,
25}
26
27/// タグ 1 個(`<` の直後から `>` の手前まで)の属性を (小文字名, 値) で返す。
28fn parse_attrs(tag: &str) -> Vec<(String, String)> {
29    let b = tag.as_bytes();
30    let mut i = 0;
31    // タグ名を飛ばす。
32    while i < b.len() && !b[i].is_ascii_whitespace() {
33        i += 1;
34    }
35    let mut out = Vec::new();
36    while i < b.len() {
37        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') {
38            i += 1;
39        }
40        let ns = i;
41        while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'=' && b[i] != b'/' {
42            i += 1;
43        }
44        let name = tag.get(ns..i).unwrap_or("").to_ascii_lowercase();
45        while i < b.len() && b[i].is_ascii_whitespace() {
46            i += 1;
47        }
48        let mut value = String::new();
49        if i < b.len() && b[i] == b'=' {
50            i += 1;
51            while i < b.len() && b[i].is_ascii_whitespace() {
52                i += 1;
53            }
54            if i < b.len() && (b[i] == b'"' || b[i] == b'\'') {
55                let q = b[i];
56                i += 1;
57                let vs = i;
58                while i < b.len() && b[i] != q {
59                    i += 1;
60                }
61                value = tag.get(vs..i).unwrap_or("").into();
62                i += 1;
63            } else {
64                let vs = i;
65                while i < b.len() && !b[i].is_ascii_whitespace() {
66                    i += 1;
67                }
68                value = tag.get(vs..i).unwrap_or("").into();
69            }
70        }
71        if !name.is_empty() {
72            out.push((name, crate::os_lib::dom::decode_entities(&value)));
73        }
74    }
75    out
76}
77
78fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
79    attrs.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
80}
81
82/// 連続する空白を 1 個にまとめ、前後を落とす。
83fn squash(s: &str) -> String {
84    let mut out = String::new();
85    for w in s.split_whitespace() {
86        if !out.is_empty() {
87            out.push(' ');
88        }
89        out.push_str(w);
90    }
91    out
92}
93
94/// HTML から代替表示の情報を読む。タイトルも説明も無ければ None。
95pub fn parse_preview(html: &str) -> Option<Preview> {
96    // `<body>` 以降は見ない(`<head>` の meta だけが対象。本文中の文字列を拾わない)。
97    let lower = html.to_ascii_lowercase();
98    let end = lower.find("<body").unwrap_or(html.len());
99    let head = html.get(..end).unwrap_or(html);
100    let lhead = lower.get(..end).unwrap_or(&lower);
101
102    let (mut og_title, mut og_desc, mut og_image) = (None, None, None);
103    let (mut meta_desc, mut tw_image) = (None, None);
104    let mut pos = 0;
105    while let Some(off) = lhead.get(pos..).and_then(|s| s.find("<meta")) {
106        let st = pos + off + 1;
107        let Some(len) = head.get(st..).and_then(|s| s.find('>')) else { break };
108        let attrs = parse_attrs(head.get(st..st + len).unwrap_or(""));
109        let key = attr(&attrs, "property").or_else(|| attr(&attrs, "name")).unwrap_or("").to_ascii_lowercase();
110        let content = attr(&attrs, "content").map(squash).filter(|c| !c.is_empty());
111        match key.as_str() {
112            "og:title" => og_title = og_title.or(content),
113            "og:description" => og_desc = og_desc.or(content),
114            "og:image" => og_image = og_image.or(content),
115            "description" => meta_desc = meta_desc.or(content),
116            "twitter:image" => tw_image = tw_image.or(content),
117            _ => {}
118        }
119        pos = st + len;
120    }
121    let title_tag = lhead.find("<title").and_then(|s| {
122        let open = s + lhead.get(s..)?.find('>')? + 1;
123        let close = open + lhead.get(open..)?.find("</title")?;
124        Some(squash(&crate::os_lib::dom::decode_entities(head.get(open..close)?)))
125    });
126    let title = og_title.or(title_tag.filter(|t| !t.is_empty())).unwrap_or_default();
127    let description = og_desc.or(meta_desc).unwrap_or_default();
128    if title.is_empty() && description.is_empty() {
129        return None;
130    }
131    Some(Preview { title, description, image: og_image.or(tw_image).unwrap_or_default() })
132}
133
134/// 行頭に置かない文字(句読点・閉じ括弧)。
135fn start_forbidden(c: char) -> bool {
136    "、。,.,.))」』】・ー!?!?".contains(c)
137}
138
139/// 行末に置かない文字(開き括弧)。
140fn end_forbidden(c: char) -> bool {
141    "((「『【".contains(c)
142}
143
144/// 説明文を幅 `max_w` で折り返した行の列。文字幅は `adv` で測る(描画側はフォントの送り幅を渡す)。
145/// 折り返し規則は本文と同じ `line_break::break_positions`。行頭・行末の空白は落とす。
146pub fn wrap_lines(text: &str, max_w: i32, adv: impl Fn(char) -> i32) -> Vec<String> {
147    let chars: Vec<char> = text.chars().collect();
148    let advs: Vec<i32> = chars.iter().map(|&c| adv(c)).collect();
149    let breaks = crate::os_lib::layout::line_break::break_positions(
150        &chars,
151        &advs,
152        max_w,
153        false,
154        start_forbidden,
155        end_forbidden,
156    );
157    let mut out = Vec::new();
158    let mut start = 0;
159    for b in breaks.into_iter().chain(core::iter::once(chars.len())) {
160        let line: String = chars.get(start..b).unwrap_or(&[]).iter().collect();
161        let line = line.trim();
162        if !line.is_empty() {
163            out.push(line.into());
164        }
165        start = b;
166    }
167    out
168}