Skip to main content

mdwire/
lib.rs

1//! mdwire — safely deliver agent-generated Markdown to chat channels.
2//!
3//! It does three things in one pipeline. The order is the design.
4//!
5//! 1. **Normalize** — LLM output is not valid CommonMark. Unbalanced emphasis,
6//!    emphasis that spans lines, and unclosed code fences are routine. Repair them first.
7//! 2. **Render for the channel** — translate into the syntax the target accepts. Syntax the
8//!    target cannot accept is not worth parsing in the first place (see the `Channel` docs below).
9//! 3. **Split safely** — at channel limits and streaming boundaries, never cut through markup.
10//!
11//! # Why no dependencies
12//!
13//! Existing parsers are all **batch** parsers — they take the whole document, build an AST,
14//! then render. That does not fit a problem where output must go out as tokens stream in.
15//! And the CJK-adjacent emphasis policy is baked into the parser, so with someone else's
16//! parser you cannot change it. That is exactly the problem this library sets out to fix.
17
18#![forbid(unsafe_code)]
19
20mod block;
21mod inline;
22mod sink;
23mod vocab;
24pub mod width;
25
26use block::Engine;
27use sink::{PartsSink, StringSink};
28use vocab::Vocab;
29
30/// Target channel. Each channel accepts a different syntax, and **the narrower output decides the
31/// parsing scope**.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Channel {
34    /// Telegram `parse_mode=HTML`. Nine allowed tags:
35    /// `b i u s code pre a blockquote tg-spoiler`. No tables or headings. 4096 characters.
36    TelegramHtml,
37    /// Slack `markdown_text`. Slack converts standard Markdown itself. 12,000 characters.
38    /// Almost no conversion is needed; what is left is normalization and splitting.
39    SlackMarkdown,
40    /// GitHub comments and PR bodies (GFM). Renders tables, headings, and strikethrough.
41    /// 65,536 characters. Emits the same Markdown as Slack, but escapes the two characters GFM
42    /// reads as syntax (`~` `<`).
43    GithubMarkdown,
44    /// Notion page body (Notion-flavored Markdown — the API `markdown` field and connectors).
45    /// Four heading levels. Emits the same Markdown as GitHub, but strips inline HTML Notion
46    /// cannot render (it would show as text), and writes autolinks `<url>` as `[url](url)` (the
47    /// angle brackets would remain as text). Emphasis before a Korean particle is rendered by
48    /// Notion as is, so it is not turned into `<strong>` — doing so would make the tag show as text.
49    NotionMarkdown,
50    /// Strips all markup. The fallback path.
51    Plain,
52    /// An HTML fragment for the browser. Renders headings, lists, tables, and code blocks as tags.
53    /// No limit.
54    ///
55    /// Assumes the output goes straight into `innerHTML` — all text is escaped, HTML from the
56    /// source survives only as inline tags with their attributes dropped, and only `http(s)` and
57    /// `mailto` links become `<a>`. Appending [`Streamer::close_open`] to the streaming
58    /// accumulated output gives a shape that can be inserted as is.
59    Html,
60}
61
62impl Channel {
63    /// The name used for corpus directories and CLI arguments. The source of truth wherever a
64    /// channel is handled as a string.
65    pub fn name(self) -> &'static str {
66        match self {
67            Channel::TelegramHtml => "telegram-html",
68            Channel::SlackMarkdown => "slack-markdown",
69            Channel::GithubMarkdown => "github-markdown",
70            Channel::NotionMarkdown => "notion-markdown",
71            Channel::Plain => "plain",
72            Channel::Html => "html",
73        }
74    }
75
76    /// Every supported channel. The corpus and the harness iterate over this list.
77    pub fn all() -> [Channel; 6] {
78        [
79            Channel::TelegramHtml,
80            Channel::SlackMarkdown,
81            Channel::GithubMarkdown,
82            Channel::NotionMarkdown,
83            Channel::Plain,
84            Channel::Html,
85        ]
86    }
87
88    /// Looks up a channel by name.
89    pub fn parse(name: &str) -> Option<Channel> {
90        Self::all().into_iter().find(|c| c.name() == name)
91    }
92
93    /// This channel's message length limit, in characters. Splitting is based on it.
94    pub fn limit(self) -> usize {
95        match self {
96            Channel::TelegramHtml => 4096,
97            Channel::SlackMarkdown | Channel::Plain => 12_000,
98            // 코멘트 본문의 한도다. 넘기면 API 가 422 로 거절한다("Body is too long").
99            Channel::GithubMarkdown => 65_536,
100            // **재지 않았다.** 페이지 하나에 들어갈 본문이라 GitHub 과 같은 값을 둔다. 보내는 쪽
101            // 한도가 따로 있으면 [`Options::limit`] 으로 준다.
102            Channel::NotionMarkdown => 65_536,
103            // 브라우저에는 메시지 한도가 없다. 나누지 않는다.
104            Channel::Html => usize::MAX,
105        }
106    }
107}
108
109/// The smallest part limit a caller can set.
110///
111/// **Each part needs room to close and reopen its markup.** If the limit is smaller than a tag,
112/// the splitter cuts between the tag's characters — splitting Telegram `**x**` with a limit of 1
113/// produced `<` · `b` · `></b>` (found in review). This value fits several nested opening tags
114/// (quote, bold, code, `<pre><code class="language-…">`) and still leaves room for content. It is
115/// far from real use (subtracting a header's share from Telegram's 4096).
116pub const MIN_LIMIT: usize = 256;
117
118/// Conversion options — the part limit and the browser channel's policy.
119///
120/// **There is no input-syntax choice.** Accepting LLM output even when it strays from the standard
121/// is the job of the default reader.
122///
123/// Fields may be added, so build it as `Options { limit, ..Default::default() }`.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct Options {
126    /// The limit for one part (characters of rendered output). `None` means [`Channel::limit`].
127    ///
128    /// **The sender decides the limit.** Sometimes the channel cannot — plain is a fallback with
129    /// no known destination, so sending it to Telegram needs 4096 (a 7,153-character part split at
130    /// 12,000 got a 400), and a sender that prepends a title must use that much less.
131    /// Streaming ([`Streamer`]) does not split, so it ignores this value — except for Notion
132    /// tables. A table over the limit comes out as several tables that repeat the header row; that
133    /// is the table's shape, so streaming emits it the same way. The browser channel
134    /// ([`Channel::Html`]) does not split either — the splitter does not close and reopen block
135    /// tags, so it would cut through the middle of a tag. Values below [`MIN_LIMIT`] are raised
136    /// to it.
137    pub limit: Option<usize>,
138    /// The browser channel's ([`Channel::Html`]) policy. Other channels ignore it.
139    pub html: HtmlOptions,
140}
141
142/// The browser channel's policy. The defaults are the most conservative — `<br>` line breaks,
143/// images as links only, and only `http`, `https`, and `mailto` links.
144#[derive(Debug, Clone, PartialEq, Eq, Default)]
145pub struct HtmlOptions {
146    pub line_breaks: LineBreaks,
147    pub images: Images,
148    /// Schemes accepted for link and image URLs (without the colon, like `"https"`). `None`
149    /// means `http`, `https`, and `mailto`. A list accepts **only** those — it does not add to the
150    /// defaults.
151    pub schemes: Option<Vec<String>>,
152}
153
154/// How to emit line breaks inside a block.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum LineBreaks {
157    /// `<br>` — for text where the author's line breaks carry meaning, like chat or notes. Every
158    /// other channel keeps line breaks.
159    #[default]
160    Br,
161    /// The newline character only — the browser folds it into a space. For reading a document
162    /// wrapped at 80 columns as paragraphs.
163    Space,
164}
165
166/// How to emit an image `![alt](url)`.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
168pub enum Images {
169    /// `<a href>alt</a>` — nothing loads until it is clicked (no tracking pixels).
170    #[default]
171    Link,
172    /// `<img src alt>` — only when the URL has an allowed scheme. Otherwise emitted like `Link`.
173    Load,
174}
175
176/// Counts of what normalization repaired and what was changed to fit the channel. The first five
177/// (repairs) measure **how often the model breaks formatting**; the last six (changes) measure
178/// **what a channel changes, before adopting it** — to ask about each separately, use
179/// [`Repairs::any`] and [`Repairs::changed`].
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub struct Repairs {
182    /// Emphasis left unclosed at the end of a block and closed for it (like `**영향 범위`).
183    pub closed_emphasis: usize,
184    /// Code fences left unclosed at the end of the document and closed for it.
185    pub closed_fence: usize,
186    /// Unmatched backtick runs turned back into text instead of code.
187    pub reverted_code_span: usize,
188    /// Orphaned `**` dropped (preceded by text, like `꼬리**`).
189    pub dropped_marker: usize,
190    /// Emphasis paired by a guess rather than by the rules — an opener CommonMark would not open
191    /// (`값**(합계)**를`, a letter before it and punctuation after) matched with a mirror-shaped closer
192    /// on the same line. Math is left out — ASCII letters or digits on both ends (`x**(y)**z`) never
193    /// pair — so what remains is the rare shape that is bold or math only by context. A non-zero
194    /// count marks an answer worth a look.
195    pub guessed_pair: usize,
196    /// Characters escaped because the channel would read them as syntax (GitHub's `\~`, `\<`,
197    /// `\*`; Slack's and Notion's `\*`).
198    pub escaped_char: usize,
199    /// Emphasis emitted differently because the channel cannot read the markers in that position
200    /// (`**「설정」**가`) — `<strong>` on GitHub, U+2060 inserted inside the markers on Slack.
201    pub tag_emphasis: usize,
202    /// Source HTML stripped — tags the channel cannot render, comments, and `<br>` turned into
203    /// line breaks.
204    pub stripped_html: usize,
205    /// List markers rewritten with a different symbol — bullets (`* `, `• ` → `- `; on Telegram
206    /// `- ` → `• `) and numbers (`1)` → `1.`).
207    pub rewritten_bullet: usize,
208    /// Tables rewritten into a different shape from the source (delimiter row and cell padding
209    /// normalized, or lowered to fixed width).
210    pub rewritten_table: usize,
211    /// Emphasis markers and links rewritten in a different notation — `_기울임_` → `*기울임*`,
212    /// `__굵게__` → `**굵게**`, `<url|텍스트>` → `[텍스트](url)`. Counted only on channels that
213    /// emit Markdown.
214    pub converted_marker: usize,
215}
216
217impl Repairs {
218    pub(crate) fn add(&mut self, other: Repairs) {
219        self.closed_emphasis += other.closed_emphasis;
220        self.closed_fence += other.closed_fence;
221        self.reverted_code_span += other.reverted_code_span;
222        self.dropped_marker += other.dropped_marker;
223        self.guessed_pair += other.guessed_pair;
224        self.escaped_char += other.escaped_char;
225        self.tag_emphasis += other.tag_emphasis;
226        self.stripped_html += other.stripped_html;
227        self.rewritten_bullet += other.rewritten_bullet;
228        self.rewritten_table += other.rewritten_table;
229        self.converted_marker += other.converted_marker;
230    }
231
232    /// Whether normalization **repaired** anything — the first five (closed emphasis and fences,
233    /// reverted backticks, dropped markers, guessed pairs). It asks whether the model broke formatting. Changes
234    /// made to fit the channel (escapes, bullets, tables …) are not counted — that is
235    /// [`Repairs::changed`].
236    pub fn any(&self) -> bool {
237        self.closed_emphasis + self.closed_fence + self.reverted_code_span + self.dropped_marker + self.guessed_pair > 0
238    }
239
240    /// Whether **anything at all** was done, repair or channel change. It asks whether the output
241    /// may differ from the source (tidying such as collapsing blank lines is not counted —
242    /// `SPEC.md` 5.1).
243    pub fn changed(&self) -> bool {
244        *self != Repairs::default()
245    }
246}
247
248/// The result of [`render_with`] — the parts and the repairs.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Rendered {
251    pub parts: Vec<String>,
252    pub repairs: Repairs,
253}
254
255/// Streaming converter.
256///
257/// Feed it chunks and it returns **only as much as is safe to emit right now**.
258/// Markup caught on a boundary (cut off at `**굵`) stays inside until the next chunk arrives.
259/// This is the core of the library — converting a finished document is a problem others have
260/// already solved, while the boundary problem shows up on every channel as long as you stream.
261///
262/// # Example
263///
264/// ```
265/// use mdwire::{Channel, Streamer};
266///
267/// let mut s = Streamer::new(Channel::TelegramHtml);
268/// let mut out = String::new();
269/// // A chunk boundary inside `**` never sends half a marker.
270/// out.push_str(s.push("앞말 **굵"));
271/// out.push_str(s.push("게** 뒷말"));
272/// out.push_str(s.finish());
273/// assert_eq!(out, "앞말 <b>굵게</b> 뒷말");
274/// ```
275pub struct Streamer {
276    engine: Engine,
277    /// [`Streamer::push`] 가 빌려주는 버퍼. 재사용하므로 조각마다 할당하지 않는다.
278    buf: String,
279    /// 줄바꿈이 아닌 글자를 하나라도 내보냈는가.
280    ///
281    /// **앞머리 빈 줄은 내보내지 않는다.** 문서가 주석이나 `<br>` 로 시작하면 첫 블록이
282    /// 비고 그 뒤의 줄바꿈만 남는데, 완성본은 조각 앞머리의 줄바꿈을 털고 시작한다
283    /// (`sink::PartsSink`). 스트리밍도 같아야 한다 — 그래야 둘이 같은 답을 낸다.
284    started: bool,
285    /// 마지막 [`Streamer::preview`] 의 꼬리. 재사용 버퍼 — 열린 블록만큼이지 문서 전체가 아니다.
286    tail: String,
287    /// `tail` 이 지금 상태를 반영하지 않는다 — 미리보기를 안 했거나 그 뒤에 조각이 더 왔다.
288    dirty: bool,
289    /// [`Streamer::revised`] 의 답. `finish` 가 정한다.
290    revised: bool,
291}
292
293impl Streamer {
294    pub fn new(channel: Channel) -> Self {
295        Self::with_options(channel, Options::default())
296    }
297
298    /// Builds one with options.
299    pub fn with_options(channel: Channel, options: Options) -> Self {
300        Self {
301            engine: Engine::new(channel, &options),
302            buf: String::new(),
303            started: false,
304            tail: String::new(),
305            dirty: true,
306            revised: true,
307        }
308    }
309
310    /// What normalization has repaired so far. After `finish`, it covers the whole document.
311    pub fn repairs(&self) -> Repairs {
312        self.engine.repairs()
313    }
314
315    /// `from` 뒤에 새로 붙은 출력에서 앞머리 줄바꿈을 턴다. 첫 글자가 나올 때까지만이다.
316    fn trim_leading(&mut self, out: &mut String, from: usize) {
317        if self.started {
318            return;
319        }
320        let fresh = &out[from..];
321        let keep = fresh.len() - fresh.trim_start_matches('\n').len();
322        if keep > 0 {
323            out.drain(from..from + keep);
324        }
325        if out.len() > from {
326            self.started = true;
327        }
328    }
329
330    /// Pushes a chunk and returns the output that can be emitted now.
331    ///
332    /// The returned slice is valid **only until the next call**. To avoid allocation entirely,
333    /// use [`Streamer::push_into`] — both call the same code (`SPEC.md` section 5).
334    pub fn push(&mut self, chunk: &str) -> &str {
335        let mut buf = std::mem::take(&mut self.buf);
336        buf.clear();
337        self.push_into(chunk, &mut buf);
338        self.buf = buf;
339        &self.buf
340    }
341
342    /// Writes directly into the caller's buffer. The canonical signature — zero allocations per
343    /// chunk.
344    pub fn push_into(&mut self, chunk: &str, out: &mut String) {
345        self.dirty |= !chunk.is_empty();
346        let from = out.len();
347        let mut sink = StringSink(out);
348        self.engine.feed(chunk, &mut sink);
349        self.trim_leading(out, from);
350    }
351
352    /// Signals the end of input. Emits everything left (open markup is closed).
353    pub fn finish(&mut self) -> &str {
354        let mut buf = std::mem::take(&mut self.buf);
355        buf.clear();
356        self.finish_into(&mut buf);
357        self.buf = buf;
358        &self.buf
359    }
360
361    /// The allocation-free version of [`Streamer::finish`].
362    pub fn finish_into(&mut self, out: &mut String) {
363        let from = out.len();
364        let mut sink = StringSink(out);
365        self.engine.finish(&mut sink);
366        self.trim_leading(out, from);
367        self.revised = self.dirty || self.tail != out[from..];
368        // 끝난 엔진에 더 그릴 꼬리는 없다. 비워 두지 않으면 뒤이은 `preview` 가 끝난 엔진을
369        // 복제해 finish 꼬리를 한 번 더 낸다.
370        self.tail.clear();
371        self.dirty = false;
372    }
373
374    /// **The tail that would follow the final output if input ended now.** Appending it to the
375    /// accumulated output gives a shape that can be sent on the spot — it goes where
376    /// [`Streamer::close_open`] goes, but also renders what is being held back: open emphasis
377    /// closed (`**굵` → `<b>굵</b>`), tables with the rows received so far, code spans closed.
378    /// It is the default for callers that redraw the whole accumulated output (React, Telegram
379    /// `editMessageText`, Slack `chat.update`).
380    ///
381    /// The tail takes the same `finish` path as batch rendering, so its syntax is always valid. But
382    /// it is a **guess** — later chunks can change the shape, for example a code span that never
383    /// closes turns back into text. After finishing, [`Streamer::revised`] tells you whether the
384    /// result differs from the last preview. Do not put it into the accumulated output itself.
385    ///
386    /// The cost is proportional to the size of the currently open block (the engine is cloned).
387    /// Call it when drawing the screen, not on every chunk.
388    ///
389    /// ```
390    /// use mdwire::{Channel, Streamer};
391    ///
392    /// let mut s = Streamer::new(Channel::TelegramHtml);
393    /// let mut acc = String::new();
394    /// s.push_into("앞말 **굵", &mut acc);
395    /// assert_eq!(acc, "앞말 ");                       // final output so far
396    /// assert_eq!(format!("{acc}{}", s.preview()), "앞말 <b>굵</b>");
397    ///
398    /// s.push_into("게** 끝", &mut acc);
399    /// let last = format!("{acc}{}", s.preview());
400    /// s.finish_into(&mut acc);
401    /// assert_eq!(acc, last);
402    /// assert!(!s.revised());                          // the last frame is already the result
403    /// ```
404    pub fn preview(&mut self) -> &str {
405        // 그 뒤로 조각이 안 왔으면 같은 답이다 — 다시 그리는 쪽은 조각과 무관하게도 자주 부른다.
406        if !self.dirty {
407            return &self.tail;
408        }
409        let mut tail = std::mem::take(&mut self.tail);
410        tail.clear();
411        self.engine.preview(&mut StringSink(&mut tail));
412        if !self.started {
413            let keep = tail.len() - tail.trim_start_matches('\n').len();
414            tail.drain(..keep);
415        }
416        self.tail = tail;
417        self.dirty = false;
418        &self.tail
419    }
420
421    /// Appends [`Streamer::preview`] to the caller's buffer.
422    pub fn preview_into(&mut self, out: &mut String) {
423        out.push_str(self.preview());
424    }
425
426    /// **Whether the final output differs from the last preview** — check it after `finish`. If
427    /// false, the last screen drawn (accumulated output + `preview`) already is the final output, so no
428    /// redraw is needed. Telegram returns 400 ("message is not modified") for an edit with the same
429    /// content, so use this to skip the last edit. It is true if there was no preview or more
430    /// chunks arrived after it — **true means "may differ"**. If you throttle edits so the last
431    /// preview comes before the last chunk, it is true even when the final output is the same;
432    /// in that case compare against the last string you sent.
433    pub fn revised(&self) -> bool {
434        self.revised
435    }
436
437    /// **Makes what has been received so far safe to send as is.** It does not touch the state,
438    /// so streaming continues after appending it.
439    ///
440    /// Emphasis is held inside until its pair arrives, so it is already balanced, but a block's
441    /// opening markup (`<blockquote>`, `<pre>`, a heading's `<b>`) goes out before the block
442    /// ends — holding output until a code block ends would not be streaming. Callers that send
443    /// the accumulated output to a channel midway (editing a message as tokens arrive) append this
444    /// right before sending. **Do not put it into the accumulated output itself** — the next chunk
445    /// continues from there.
446    ///
447    /// ```
448    /// use mdwire::{Channel, Streamer};
449    ///
450    /// let mut s = Streamer::new(Channel::TelegramHtml);
451    /// let mut acc = String::new();
452    /// s.push_into("> 인용이 시작되고", &mut acc);
453    ///
454    /// let mut snapshot = acc.clone();
455    /// s.close_open(&mut snapshot);          // safe to send now
456    /// assert_eq!(snapshot, "<blockquote>인용이 시작되고</blockquote>");
457    ///
458    /// s.push_into("\n> 이어진다\n", &mut acc);  // the accumulated output just continues
459    /// s.finish_into(&mut acc);
460    /// assert_eq!(acc, "<blockquote>인용이 시작되고\n이어진다</blockquote>");
461    /// ```
462    pub fn close_open(&self, out: &mut String) {
463        self.engine.close_open(out);
464    }
465}
466
467/// Converts a finished document in one go. Splits at safe points when it exceeds the limit.
468///
469/// Split points are chosen **from the structure, not the rendered output** — only points where a
470/// block has ended and no markup is open become boundaries. Cutting by character count after
471/// conversion leaves `<code>` open across the cut, and the channel returns 400 (`DESIGN.md`).
472///
473/// # Example
474///
475/// ```
476/// use mdwire::{render, Channel};
477///
478/// let parts = render("## 제목\n\n**굵게** 있는 문단", Channel::TelegramHtml);
479/// assert_eq!(parts, vec!["<b>제목</b>\n\n<b>굵게</b> 있는 문단"]);
480/// ```
481pub fn render(input: &str, channel: Channel) -> Vec<String> {
482    render_with(input, channel, Options::default()).parts
483}
484
485/// [`render`] with options; also returns what normalization repaired.
486///
487/// ```
488/// use mdwire::{render_with, Channel, Options};
489///
490/// let options = Options { limit: Some(4096), ..Default::default() };
491/// let out = render_with("**굵게** 는 **영향 범위", Channel::SlackMarkdown, options);
492/// assert_eq!(out.parts, vec!["**굵게** 는 **영향 범위**"]);
493/// assert_eq!(out.repairs.closed_emphasis, 1);
494/// ```
495pub fn render_with(input: &str, channel: Channel, options: Options) -> Rendered {
496    let mut engine = Engine::new(channel, &options);
497    let mut sink = PartsSink::new(Vocab::from_options(channel, &options));
498    engine.feed(input, &mut sink);
499    engine.finish(&mut sink);
500    Rendered { repairs: engine.repairs(), parts: sink.into_parts() }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn channel_limits_are_channel_specific() {
509        assert_eq!(Channel::TelegramHtml.limit(), 4096);
510        assert_eq!(Channel::SlackMarkdown.limit(), 12_000);
511        assert_eq!(Channel::GithubMarkdown.limit(), 65_536);
512    }
513
514    /// **조각 하나가 곧 메시지 하나다.** 태그 한가운데서 끊으면 조각이 `… <a ` 로
515    /// 끝나고, 채널은 그 메시지를 통째로 거절한다. 실제 문서(링크가 달린 긴 목록)에서
516    /// 나온 고장이다.
517    #[test]
518    fn parts_never_end_inside_a_tag() {
519        let mut input = String::new();
520        for i in 0..40 {
521            input.push_str(&format!(
522                "- `method{i}(options?: SomeLongOptionsType{i}): string` — 주소의 뒤집힌 꼴을 \
523                 돌려준다 [src](https://example.com/owner/repo/blob/master/src/mod{i}.ts#L{i}28)\n"
524            ));
525        }
526        let parts = render(&input, Channel::TelegramHtml);
527        assert!(parts.len() > 1, "한도를 넘겨서 나뉘어야 하는 입력이다");
528        for (i, p) in parts.iter().enumerate() {
529            assert_eq!(
530                p.matches('<').count(),
531                p.matches('>').count(),
532                "조각 {i} 가 태그 한가운데서 끊겼다: …{}",
533                &p[p.len().saturating_sub(40)..]
534            );
535            assert!(p.chars().count() <= Channel::TelegramHtml.limit(), "조각 {i} 가 한도를 넘었다");
536        }
537    }
538}