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 ``.
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}