marktip 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {marktip-0.1.0 → marktip-0.2.0}/.github/workflows/build-distributions.yml +24 -0
  2. {marktip-0.1.0 → marktip-0.2.0}/CMakeLists.txt +1 -1
  3. {marktip-0.1.0 → marktip-0.2.0}/PKG-INFO +12 -3
  4. {marktip-0.1.0 → marktip-0.2.0}/README.md +11 -2
  5. {marktip-0.1.0 → marktip-0.2.0}/pyproject.toml +1 -1
  6. {marktip-0.1.0 → marktip-0.2.0}/src/marktip.cpp +4 -2
  7. {marktip-0.1.0 → marktip-0.2.0}/src/parser.cpp +4 -4
  8. {marktip-0.1.0 → marktip-0.2.0}/src/parser.h +1 -1
  9. {marktip-0.1.0 → marktip-0.2.0}/src/serializer.cpp +163 -9
  10. {marktip-0.1.0 → marktip-0.2.0}/tests/test_parse.py +14 -0
  11. marktip-0.2.0/tests/test_roundtrip.py +246 -0
  12. {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/md4c.c +68 -4
  13. {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/md4c.h +5 -0
  14. marktip-0.2.0/third_party/md4c/upstream_metadata/README.md +21 -0
  15. marktip-0.1.0/tests/test_roundtrip.py +0 -58
  16. marktip-0.1.0/third_party/md4c/upstream_metadata/README.md +0 -12
  17. {marktip-0.1.0 → marktip-0.2.0}/.gitignore +0 -0
  18. {marktip-0.1.0 → marktip-0.2.0}/LICENSE +0 -0
  19. {marktip-0.1.0 → marktip-0.2.0}/python/marktip/__init__.py +0 -0
  20. {marktip-0.1.0 → marktip-0.2.0}/scripts/benchmark.py +0 -0
  21. {marktip-0.1.0 → marktip-0.2.0}/src/ast.cpp +0 -0
  22. {marktip-0.1.0 → marktip-0.2.0}/src/ast.h +0 -0
  23. {marktip-0.1.0 → marktip-0.2.0}/src/serializer.h +0 -0
  24. {marktip-0.1.0 → marktip-0.2.0}/tests/test_document.py +0 -0
  25. {marktip-0.1.0 → marktip-0.2.0}/tests/test_serialize.py +0 -0
  26. {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/LICENSE.md +0 -0
  27. {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/entity.c +0 -0
  28. {marktip-0.1.0 → marktip-0.2.0}/third_party/md4c/src/entity.h +0 -0
@@ -63,3 +63,27 @@ jobs:
63
63
  with:
64
64
  name: marktip-wheels-${{ matrix.os }}
65
65
  path: wheelhouse/*.whl
66
+
67
+ publish:
68
+ name: Publish to PyPI
69
+ needs: [sdist, wheels]
70
+ runs-on: ubuntu-latest
71
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
72
+
73
+ environment:
74
+ name: pypi
75
+ url: https://pypi.org/p/marktip
76
+
77
+ permissions:
78
+ id-token: write
79
+
80
+ steps:
81
+ - name: Download built distributions
82
+ uses: actions/download-artifact@v6
83
+ with:
84
+ pattern: marktip-*
85
+ path: dist
86
+ merge-multiple: true
87
+
88
+ - name: Publish to PyPI
89
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
7
7
  set(CMAKE_CXX_EXTENSIONS OFF)
8
8
 
9
9
  if(NOT DEFINED SKBUILD_PROJECT_VERSION)
10
- set(SKBUILD_PROJECT_VERSION "0.1.0")
10
+ set(SKBUILD_PROJECT_VERSION "0.2.0")
11
11
  endif()
12
12
 
13
13
  set(PYBIND11_FINDPYTHON ON)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marktip
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Fast C++/MD4C Markdown parser and canonical Markdown serializer for Tiptap-style JSON
5
5
  Keywords: markdown,tiptap,md4c,parser
6
6
  Author: Saneaven (Minjae Kyung)
@@ -52,8 +52,17 @@ markdown = doc.to_markdown()
52
52
  doc = tm.from_dict(ast)
53
53
  ```
54
54
 
55
- The first version targets GFM core syntax and canonical Markdown output rather
56
- than byte-identical source preservation.
55
+ `from_markdown` follows GFM/CommonMark by default. Pass `cjk_friendly=True` to
56
+ relax the emphasis and strikethrough rules so delimiters next to CJK text still
57
+ open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that
58
+ is off by default:
59
+
60
+ ```python
61
+ doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
62
+ ```
63
+
64
+ marktip targets GFM core syntax and canonical Markdown output rather than
65
+ byte-identical source preservation.
57
66
 
58
67
  ## Development
59
68
 
@@ -24,8 +24,17 @@ markdown = doc.to_markdown()
24
24
  doc = tm.from_dict(ast)
25
25
  ```
26
26
 
27
- The first version targets GFM core syntax and canonical Markdown output rather
28
- than byte-identical source preservation.
27
+ `from_markdown` follows GFM/CommonMark by default. Pass `cjk_friendly=True` to
28
+ relax the emphasis and strikethrough rules so delimiters next to CJK text still
29
+ open and close (e.g. `**볼드**은` parses as bold), a non-standard extension that
30
+ is off by default:
31
+
32
+ ```python
33
+ doc = tm.from_markdown("**볼드**은 강조", cjk_friendly=True)
34
+ ```
35
+
36
+ marktip targets GFM core syntax and canonical Markdown output rather than
37
+ byte-identical source preservation.
29
38
 
30
39
  ## Development
31
40
 
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "marktip"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Fast C++/MD4C Markdown parser and canonical Markdown serializer for Tiptap-style JSON"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -6,7 +6,7 @@
6
6
  namespace py = pybind11;
7
7
 
8
8
  #ifndef MARKTIP_VERSION
9
- #define MARKTIP_VERSION "0.1.0"
9
+ #define MARKTIP_VERSION "0.2.0"
10
10
  #endif
11
11
 
12
12
  PYBIND11_MODULE(_core, module) {
@@ -21,6 +21,8 @@ PYBIND11_MODULE(_core, module) {
21
21
  },
22
22
  "Serialize the document AST to canonical Markdown.");
23
23
 
24
- module.def("from_markdown", &marktip::from_markdown_py, py::arg("markdown"), "Parse Markdown into a Document.");
24
+ module.def("from_markdown", &marktip::from_markdown_py, py::arg("markdown"), py::arg("cjk_friendly") = false,
25
+ "Parse Markdown into a Document. Set cjk_friendly=True to relax the emphasis rules around CJK text "
26
+ "(non-standard extension; the default follows GFM/CommonMark exactly).");
25
27
  module.def("from_dict", &marktip::from_dict_py, py::arg("ast"), "Build a Document from a Tiptap-style JSON dict.");
26
28
  }
@@ -704,11 +704,11 @@ int text_callback(MD_TEXTTYPE type, const MD_CHAR* text, MD_SIZE size, void* use
704
704
  }
705
705
  }
706
706
 
707
- Document parse_to_document(const std::string& markdown) {
707
+ Document parse_to_document(const std::string& markdown, bool cjk_friendly) {
708
708
  AstBuilder builder(markdown.size());
709
709
  MD_PARSER parser {};
710
710
  parser.abi_version = 0;
711
- parser.flags = MD_DIALECT_GITHUB;
711
+ parser.flags = MD_DIALECT_GITHUB | (cjk_friendly ? MD_FLAG_CJKFRIENDLYEMPHASIS : 0);
712
712
  parser.enter_block = enter_block_callback;
713
713
  parser.leave_block = leave_block_callback;
714
714
  parser.enter_span = enter_span_callback;
@@ -729,7 +729,7 @@ Document parse_to_document(const std::string& markdown) {
729
729
 
730
730
  } // namespace
731
731
 
732
- Document from_markdown_py(py::object markdown) {
732
+ Document from_markdown_py(py::object markdown, bool cjk_friendly) {
733
733
  std::string input;
734
734
  if (py::isinstance<py::str>(markdown) || py::isinstance<py::bytes>(markdown)) {
735
735
  input = py::cast<std::string>(markdown);
@@ -740,7 +740,7 @@ Document from_markdown_py(py::object markdown) {
740
740
  Document document;
741
741
  {
742
742
  py::gil_scoped_release release;
743
- document = parse_to_document(input);
743
+ document = parse_to_document(input, cjk_friendly);
744
744
  }
745
745
  return document;
746
746
  }
@@ -6,6 +6,6 @@
6
6
 
7
7
  namespace marktip {
8
8
 
9
- Document from_markdown_py(pybind11::object markdown);
9
+ Document from_markdown_py(pybind11::object markdown, bool cjk_friendly);
10
10
 
11
11
  } // namespace marktip
@@ -211,6 +211,150 @@ std::string apply_marks(std::string rendered, const std::vector<Mark>& marks, bo
211
211
  return rendered;
212
212
  }
213
213
 
214
+ // A run of consecutive inline nodes sharing the same marks, with its content
215
+ // already rendered. `marks` excludes "code" (code spans are atomic per run).
216
+ struct InlineRun {
217
+ std::vector<Mark> marks;
218
+ std::string content;
219
+ };
220
+
221
+ bool is_emphasis_mark(const Mark& mark) {
222
+ return mark.type == "bold" || mark.type == "italic" || mark.type == "strike";
223
+ }
224
+
225
+ std::size_t common_prefix(const std::vector<Mark>& a, const std::vector<Mark>& b) {
226
+ std::size_t limit = std::min(a.size(), b.size());
227
+ std::size_t i = 0;
228
+ while (i < limit && a[i] == b[i]) {
229
+ ++i;
230
+ }
231
+ return i;
232
+ }
233
+
234
+ std::size_t first_emphasis_at_or_after(const std::vector<Mark>& marks, std::size_t from) {
235
+ for (std::size_t i = from; i < marks.size(); ++i) {
236
+ if (is_emphasis_mark(marks[i])) {
237
+ return i;
238
+ }
239
+ }
240
+ return marks.size();
241
+ }
242
+
243
+ std::string open_delimiter(const Mark& mark) {
244
+ if (mark.type == "bold") {
245
+ return "**";
246
+ }
247
+ if (mark.type == "italic") {
248
+ return "*";
249
+ }
250
+ if (mark.type == "strike") {
251
+ return "~~";
252
+ }
253
+ if (mark.type == "link") {
254
+ return "[";
255
+ }
256
+ return "";
257
+ }
258
+
259
+ std::string close_delimiter(const Mark& mark) {
260
+ if (mark.type == "bold") {
261
+ return "**";
262
+ }
263
+ if (mark.type == "italic") {
264
+ return "*";
265
+ }
266
+ if (mark.type == "strike") {
267
+ return "~~";
268
+ }
269
+ if (mark.type == "link") {
270
+ std::string href = mark_attr_string(mark, "href");
271
+ std::string title = mark_attr_string(mark, "title");
272
+ return "](" + escape_link_destination(href) +
273
+ (title.empty() ? "" : " \"" + escape_title(title) + "\"") + ")";
274
+ }
275
+ return "";
276
+ }
277
+
278
+ // Whitespace adjacent to an emphasis delimiter prevents the delimiter from
279
+ // opening/closing and no markdown syntax can express it, so such whitespace is
280
+ // moved outside the emphasis marks (cf. prosemirror-markdown
281
+ // expelEnclosingWhitespace). It stays inside enclosing non-emphasis marks
282
+ // such as links, which have no whitespace restriction.
283
+ void expel_boundary_whitespace(std::vector<InlineRun>& runs) {
284
+ static const char* const kWhitespace = " \t\n";
285
+ const std::vector<Mark> no_marks;
286
+ std::vector<InlineRun> result;
287
+
288
+ for (std::size_t i = 0; i < runs.size(); ++i) {
289
+ const std::vector<Mark>& prev = i > 0 ? runs[i - 1].marks : no_marks;
290
+ const std::vector<Mark>& next = i + 1 < runs.size() ? runs[i + 1].marks : no_marks;
291
+ InlineRun& run = runs[i];
292
+
293
+ std::size_t opening_emphasis = first_emphasis_at_or_after(run.marks, common_prefix(prev, run.marks));
294
+ if (opening_emphasis < run.marks.size()) {
295
+ std::size_t body = run.content.find_first_not_of(kWhitespace);
296
+ std::size_t lead_len = body == std::string::npos ? run.content.size() : body;
297
+ if (lead_len > 0) {
298
+ InlineRun lead;
299
+ lead.marks.assign(run.marks.begin(), run.marks.begin() + opening_emphasis);
300
+ lead.content = run.content.substr(0, lead_len);
301
+ run.content.erase(0, lead_len);
302
+ result.push_back(std::move(lead));
303
+ }
304
+ }
305
+
306
+ InlineRun tail;
307
+ bool has_tail = false;
308
+ std::size_t closing_emphasis = first_emphasis_at_or_after(run.marks, common_prefix(run.marks, next));
309
+ if (closing_emphasis < run.marks.size() && !run.content.empty()) {
310
+ std::size_t body = run.content.find_last_not_of(kWhitespace);
311
+ std::size_t keep = body == std::string::npos ? 0 : body + 1;
312
+ if (keep < run.content.size()) {
313
+ tail.marks.assign(run.marks.begin(), run.marks.begin() + closing_emphasis);
314
+ tail.content = run.content.substr(keep);
315
+ run.content.erase(keep);
316
+ has_tail = true;
317
+ }
318
+ }
319
+
320
+ if (!run.content.empty()) {
321
+ InlineRun kept;
322
+ kept.marks = run.marks;
323
+ kept.content = std::move(run.content);
324
+ result.push_back(std::move(kept));
325
+ }
326
+ if (has_tail) {
327
+ result.push_back(std::move(tail));
328
+ }
329
+ }
330
+
331
+ runs = std::move(result);
332
+ }
333
+
334
+ // Emit runs keeping shared marks open across run boundaries, so e.g.
335
+ // [a: bold][b: bold+italic] becomes "**a*b***" rather than "**a*****b***".
336
+ std::string emit_runs(const std::vector<InlineRun>& runs) {
337
+ std::string out;
338
+ std::vector<Mark> open;
339
+ for (const InlineRun& run : runs) {
340
+ std::size_t keep = common_prefix(open, run.marks);
341
+ while (open.size() > keep) {
342
+ out += close_delimiter(open.back());
343
+ open.pop_back();
344
+ }
345
+ for (std::size_t j = keep; j < run.marks.size(); ++j) {
346
+ out += open_delimiter(run.marks[j]);
347
+ open.push_back(run.marks[j]);
348
+ }
349
+ out += run.content;
350
+ }
351
+ while (!open.empty()) {
352
+ out += close_delimiter(open.back());
353
+ open.pop_back();
354
+ }
355
+ return out;
356
+ }
357
+
214
358
  class MarkdownWriter {
215
359
  public:
216
360
  explicit MarkdownWriter(const Document& document) : document_(document) {}
@@ -483,9 +627,9 @@ private:
483
627
  return rendered;
484
628
  }
485
629
 
486
- // Render one inline node without applying its marks. Marks are applied once
487
- // around each run of consecutive nodes sharing the same marks, so a hard
488
- // break inside e.g. bold yields "**a \nb**" rather than "**a**** \n****b**".
630
+ // Render one inline node without applying its marks. Marks are emitted by
631
+ // emit_runs(), which keeps marks shared between consecutive runs open
632
+ // across the boundary.
489
633
  std::string render_inline_content(const Node& node, bool table_cell, bool in_code) {
490
634
  const std::string& type = node.type;
491
635
  if (type == "text") {
@@ -512,7 +656,13 @@ private:
512
656
  return render_inline(node, table_cell);
513
657
  }
514
658
 
515
- std::string out;
659
+ std::vector<InlineRun> runs = build_runs(node, table_cell);
660
+ expel_boundary_whitespace(runs);
661
+ return emit_runs(runs);
662
+ }
663
+
664
+ std::vector<InlineRun> build_runs(const Node& node, bool table_cell) {
665
+ std::vector<InlineRun> runs;
516
666
  const std::vector<std::size_t>& content = node.content;
517
667
  std::size_t i = 0;
518
668
  while (i < content.size()) {
@@ -528,14 +678,18 @@ private:
528
678
  group += render_inline_content(child, table_cell, in_code);
529
679
  ++j;
530
680
  }
531
- if (in_code) {
532
- out += apply_marks(code_span(group), first.marks, true);
533
- } else {
534
- out += apply_marks(std::move(group), first.marks);
681
+
682
+ InlineRun run;
683
+ for (const Mark& mark : first.marks) {
684
+ if (mark.type != "code") {
685
+ run.marks.push_back(mark);
686
+ }
535
687
  }
688
+ run.content = in_code ? code_span(group) : std::move(group);
689
+ runs.push_back(std::move(run));
536
690
  i = j;
537
691
  }
538
- return out;
692
+ return runs;
539
693
  }
540
694
 
541
695
  std::string render_inline(const Node& node, bool table_cell = false) {
@@ -81,5 +81,19 @@ def test_parse_maps_br_in_table_cell_to_hard_break():
81
81
  ]
82
82
 
83
83
 
84
+ def test_parse_cjk_friendly_is_opt_in():
85
+ md = "**마크다운(Markdown)**은 표준이다"
86
+
87
+ relaxed = tm.from_markdown(md, cjk_friendly=True).to_dict()
88
+ assert relaxed["content"][0]["content"][0] == {
89
+ "type": "text",
90
+ "text": "마크다운(Markdown)",
91
+ "marks": [{"type": "bold"}],
92
+ }
93
+
94
+ standard = tm.from_markdown(md).to_dict()
95
+ assert standard["content"][0]["content"][0]["text"].startswith("**")
96
+
97
+
84
98
  def test_parse_accepts_bytes():
85
99
  assert tm.from_markdown(b"# Bytes").to_dict()["content"][0]["content"][0]["text"] == "Bytes"
@@ -0,0 +1,246 @@
1
+ import pytest
2
+
3
+ import marktip as tm
4
+
5
+
6
+ def assert_stable(markdown: str):
7
+ doc = tm.from_markdown(markdown)
8
+ serialized = doc.to_markdown()
9
+ assert tm.from_markdown(serialized).to_dict() == doc.to_dict()
10
+
11
+
12
+ def test_roundtrip_core_blocks_and_marks():
13
+ assert_stable(
14
+ "# Title\n\n"
15
+ "A **bold** and *italic* paragraph with `code` and ~~strike~~.\n\n"
16
+ "> quoted\n\n"
17
+ "- one\n"
18
+ "- two\n\n"
19
+ "1. first\n"
20
+ "2. second\n"
21
+ )
22
+
23
+
24
+ def test_roundtrip_gfm_table_task_list_and_code():
25
+ assert_stable(
26
+ "- [x] done\n"
27
+ "- [ ] todo\n\n"
28
+ "| A | B |\n"
29
+ "| :- | -: |\n"
30
+ "| x | y |\n\n"
31
+ "```python\n"
32
+ "print('ok')\n"
33
+ "```\n"
34
+ )
35
+
36
+
37
+ def test_roundtrip_multi_block_list_items_and_loose_lists():
38
+ assert_stable("- first\n\n second\n")
39
+ assert_stable("- a\n\n- b\n")
40
+
41
+
42
+ def test_roundtrip_hard_break_inside_marks():
43
+ assert_stable("**a\\\nb**")
44
+ assert_stable("[a\\\nb](https://example.com)")
45
+
46
+
47
+ def test_roundtrip_block_markers_escaped_in_text():
48
+ assert_stable("\\- not a list")
49
+ assert_stable("\\> not a quote")
50
+ assert_stable("1\\. not ordered")
51
+
52
+
53
+ def test_roundtrip_ordered_task_list():
54
+ assert_stable("1. [x] done\n2. [ ] todo\n")
55
+
56
+
57
+ def test_roundtrip_large_and_deep_smoke():
58
+ nested = "- root\n - child\n - grandchild\n"
59
+ large = "\n".join(f"Paragraph {i} with **bold** text." for i in range(200))
60
+ assert_stable(nested + "\n" + large)
61
+
62
+
63
+ # ─────────────────────────────────────────────────────────────────────────
64
+ # Adversarial round-trip regressions.
65
+ #
66
+ # The tests above check md -> AST -> md -> AST stability for markdown
67
+ # input. The tests below cover the two remaining invariants of a canonical
68
+ # pipeline:
69
+ #
70
+ # 1. AST identity: from_dict -> to_markdown -> from_markdown == input
71
+ # (an editor document survives serialization; no mark silently drops)
72
+ # 2. Byte idempotence: normalize(normalize(md)) == normalize(md)
73
+ # (re-saving an unchanged document never changes bytes)
74
+ #
75
+ # CJK text next to emphasis delimiters is not recognized by standard
76
+ # CommonMark flanking rules; those cases round-trip only under the
77
+ # non-standard `cjk_friendly=True` parsing extension, so the tests pass the
78
+ # flag explicitly. Whitespace touching an emphasis boundary has no valid
79
+ # markdown representation at all; the serializer expels it outside the
80
+ # delimiters, and the tests assert that normalization.
81
+
82
+ # ── helpers ──────────────────────────────────────────────────────────────
83
+
84
+
85
+ def doc(*blocks):
86
+ return {"type": "doc", "content": list(blocks)}
87
+
88
+
89
+ def p(*children):
90
+ return {"type": "paragraph", "content": list(children)}
91
+
92
+
93
+ def text(value, *marks):
94
+ node = {"type": "text", "text": value}
95
+ if marks:
96
+ node["marks"] = [{"type": mark} for mark in marks]
97
+ return node
98
+
99
+
100
+ def hard_break(*marks):
101
+ node = {"type": "hardBreak"}
102
+ if marks:
103
+ node["marks"] = [{"type": mark} for mark in marks]
104
+ return node
105
+
106
+
107
+ def normalize(md):
108
+ return tm.from_markdown(md).to_markdown()
109
+
110
+
111
+ def assert_ast_roundtrip(ast, cjk_friendly=False):
112
+ md = tm.from_dict(ast).to_markdown()
113
+ back = tm.from_markdown(md, cjk_friendly=cjk_friendly).to_dict()
114
+ assert back == ast, f"serialized markdown: {md!r}"
115
+
116
+
117
+ # ── regression guards: currently passing, must stay green ───────────────
118
+
119
+
120
+ def test_plain_cjk_bold():
121
+ assert_ast_roundtrip(doc(p(text("볼드", "bold"), text("입니다"))))
122
+
123
+
124
+ def test_paren_ordered_marker_escaped():
125
+ # '1)' is a valid CommonMark ordered-list marker; global paren escaping
126
+ # currently prevents the reparse. Guards against relaxing escape_inline.
127
+ assert_ast_roundtrip(doc(p(text("1) 항목처럼 보이는 문장"))))
128
+
129
+
130
+ def test_dot_ordered_marker_escaped():
131
+ assert_ast_roundtrip(doc(p(text("2026. 연간 회고"))))
132
+
133
+
134
+ def test_thematic_break_lookalike():
135
+ assert_ast_roundtrip(doc(p(text("---"))))
136
+
137
+
138
+ def test_setext_lookalike_after_hard_break():
139
+ assert_ast_roundtrip(doc(p(text("제목처럼"), hard_break(), text("==="))))
140
+
141
+
142
+ def test_code_mark_with_backticks():
143
+ assert_ast_roundtrip(doc(p(text("a`b", "code"))))
144
+
145
+
146
+ def test_hard_break_inside_bold():
147
+ assert_ast_roundtrip(
148
+ doc(p(text("첫 줄", "bold"), hard_break("bold"), text("둘째 줄", "bold")))
149
+ )
150
+
151
+
152
+ # ── AST identity: CJK-adjacent emphasis (needs cjk_friendly parsing) ─────
153
+
154
+
155
+ def test_bold_before_cjk_particle():
156
+ # Standard flanking rejects `)**은` (punctuation before, letter after);
157
+ # the cjk_friendly extension accepts it.
158
+ assert_ast_roundtrip(
159
+ doc(p(text("마크다운(Markdown)", "bold"), text("은 표준이다"))),
160
+ cjk_friendly=True,
161
+ )
162
+
163
+
164
+ def test_italic_ending_with_fullwidth_stop():
165
+ # `*강조。*다음` — U+3002 is punctuation, '다' is a CJK letter.
166
+ assert_ast_roundtrip(
167
+ doc(p(text("강조。", "italic"), text("다음 문장"))), cjk_friendly=True
168
+ )
169
+
170
+
171
+ def test_bold_quoted_word_before_particle():
172
+ # `**"인용"**라고` — '"' before the closing delimiter, CJK letter after.
173
+ assert_ast_roundtrip(
174
+ doc(p(text('"인용"', "bold"), text("라고 말했다"))), cjk_friendly=True
175
+ )
176
+
177
+
178
+ def test_strike_before_cjk_particle():
179
+ # `~~삭제~~은` — the strikethrough closer is also blocked by standard rules.
180
+ assert_ast_roundtrip(
181
+ doc(p(text("삭제", "strike"), text("은 취소선"))), cjk_friendly=True
182
+ )
183
+
184
+
185
+ def test_cjk_relaxation_is_opt_in():
186
+ # Default parsing must stay exactly GFM/CommonMark: the emphasis does not
187
+ # close, so the delimiters remain literal text.
188
+ md = tm.from_dict(
189
+ doc(p(text("마크다운(Markdown)", "bold"), text("은 표준이다")))
190
+ ).to_markdown()
191
+ reparsed = tm.from_markdown(md).to_dict()
192
+ assert reparsed["content"][0]["content"][0]["text"].startswith("**")
193
+
194
+
195
+ # ── boundary whitespace: expelled outside the delimiters ─────────────────
196
+ # Whitespace touching an emphasis delimiter has no valid markdown
197
+ # representation, so the serializer moves it outside the marks
198
+ # (cf. prosemirror-markdown expelEnclosingWhitespace). Text content and
199
+ # rendering are unchanged; only the space's mark membership normalizes.
200
+
201
+
202
+ def test_trailing_space_inside_bold_is_expelled():
203
+ ast = doc(p(text("굵게 ", "bold"), text("이후 텍스트")))
204
+ md = tm.from_dict(ast).to_markdown()
205
+ assert md == "**굵게** 이후 텍스트"
206
+ assert tm.from_markdown(md).to_dict() == doc(
207
+ p(text("굵게", "bold"), text(" 이후 텍스트"))
208
+ )
209
+
210
+
211
+ def test_leading_space_inside_bold_is_expelled():
212
+ ast = doc(p(text("앞"), text(" 굵게", "bold")))
213
+ md = tm.from_dict(ast).to_markdown()
214
+ assert md == "앞 **굵게**"
215
+ assert tm.from_markdown(md).to_dict() == doc(p(text("앞 "), text("굵게", "bold")))
216
+
217
+
218
+ # ── mark runs: shared marks stay open across run boundaries ──────────────
219
+
220
+
221
+ def test_adjacent_bold_to_bold_italic():
222
+ # Serializes to `**a*b***`: the shared bold stays open, so the reparse
223
+ # restores the exact mark order.
224
+ assert_ast_roundtrip(doc(p(text("a", "bold"), text("b", "bold", "italic"))))
225
+
226
+
227
+ # ── idempotence: normalize twice == normalize once ───────────────────────
228
+
229
+ IDEMPOTENCE_CORPUS = [
230
+ "**a*****b***",
231
+ "これは**私のやりたかったこと。**だからするの。",
232
+ "**마크다운(Markdown)**은 표준이다",
233
+ "**굵게 **이후",
234
+ "\\*리터럴\\* 별표",
235
+ "- 항목 하나\n- 항목 둘",
236
+ "1. one\n1. two",
237
+ "> 인용\n>\n> 두 문단",
238
+ "`code` 와 **bold** 혼합",
239
+ "a \nb",
240
+ ]
241
+
242
+
243
+ @pytest.mark.parametrize("md", IDEMPOTENCE_CORPUS)
244
+ def test_normalize_is_idempotent(md):
245
+ once = normalize(md)
246
+ assert normalize(once) == once, f"first pass produced: {once!r}"
@@ -678,6 +678,42 @@ struct MD_UNICODE_FOLD_INFO_tag {
678
678
  return (md_unicode_bsearch__(codepoint, PUNCT_MAP, SIZEOF_ARRAY(PUNCT_MAP)) >= 0);
679
679
  }
680
680
 
681
+ /* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS):
682
+ * CJK letters (Hangul, kana, Han ideographs, fullwidth forms). Used to
683
+ * relax the emphasis flanking rules around CJK text. */
684
+ static int
685
+ md_is_cjk_char__(unsigned codepoint)
686
+ {
687
+ #define R(cp_min, cp_max) ((cp_min) | 0x40000000), ((cp_max) | 0x80000000)
688
+ #define S(cp) (cp)
689
+ static const unsigned CJK_MAP[] = {
690
+ R(0x1100,0x11ff), /* Hangul jamo */
691
+ S(0x3005), S(0x3007), S(0x303b),
692
+ R(0x3040,0x30fa), /* hiragana, katakana (except middle dot) */
693
+ R(0x30fc,0x30ff),
694
+ R(0x3105,0x312f), /* bopomofo */
695
+ R(0x3130,0x318f), /* Hangul compatibility jamo */
696
+ R(0x31f0,0x31ff), /* katakana phonetic extensions */
697
+ R(0x3400,0x4dbf), /* CJK ideographs extension A */
698
+ R(0x4e00,0x9fff), /* CJK unified ideographs */
699
+ R(0xa960,0xa97f), /* Hangul jamo extended-A */
700
+ R(0xac00,0xd7a3), /* Hangul syllables */
701
+ R(0xd7b0,0xd7ff), /* Hangul jamo extended-B */
702
+ R(0xf900,0xfaff), /* CJK compatibility ideographs */
703
+ R(0xff21,0xff3a), /* fullwidth Latin capitals */
704
+ R(0xff41,0xff5a), /* fullwidth Latin small letters */
705
+ R(0xff66,0xff9f), /* halfwidth katakana */
706
+ R(0x20000,0x3ffff) /* CJK ideographs extensions B..H */
707
+ };
708
+ #undef R
709
+ #undef S
710
+
711
+ if(codepoint < 0x1100)
712
+ return FALSE;
713
+
714
+ return (md_unicode_bsearch__(codepoint, CJK_MAP, SIZEOF_ARRAY(CJK_MAP)) >= 0);
715
+ }
716
+
681
717
  static void
682
718
  md_get_unicode_fold_info(unsigned codepoint, MD_UNICODE_FOLD_INFO* info)
683
719
  {
@@ -871,6 +907,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
871
907
  #define ISUNICODEPUNCT(off) md_is_unicode_punct__(md_decode_utf16le__(STR(off), ctx->size - (off), NULL))
872
908
  #define ISUNICODEPUNCTBEFORE(off) md_is_unicode_punct__(md_decode_utf16le_before__(ctx, off))
873
909
 
910
+ /* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS) */
911
+ #define ISCJK(off) md_is_cjk_char__(md_decode_utf16le__(STR(off), ctx->size - (off), NULL))
912
+ #define ISCJKBEFORE(off) md_is_cjk_char__(md_decode_utf16le_before__(ctx, off))
913
+
874
914
  static inline int
875
915
  md_decode_unicode(const CHAR* str, OFF off, SZ str_size, SZ* p_char_size)
876
916
  {
@@ -952,6 +992,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
952
992
  #define ISUNICODEPUNCT(off) md_is_unicode_punct__(md_decode_utf8__(STR(off), ctx->size - (off), NULL))
953
993
  #define ISUNICODEPUNCTBEFORE(off) md_is_unicode_punct__(md_decode_utf8_before__(ctx, off))
954
994
 
995
+ /* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS) */
996
+ #define ISCJK(off) md_is_cjk_char__(md_decode_utf8__(STR(off), ctx->size - (off), NULL))
997
+ #define ISCJKBEFORE(off) md_is_cjk_char__(md_decode_utf8_before__(ctx, off))
998
+
955
999
  static inline unsigned
956
1000
  md_decode_unicode(const CHAR* str, OFF off, SZ str_size, SZ* p_char_size)
957
1001
  {
@@ -965,6 +1009,10 @@ struct MD_UNICODE_FOLD_INFO_tag {
965
1009
  #define ISUNICODEPUNCT(off) ISPUNCT(off)
966
1010
  #define ISUNICODEPUNCTBEFORE(off) ISPUNCT((off)-1)
967
1011
 
1012
+ /* MARKTIP LOCAL PATCH (MD_FLAG_CJKFRIENDLYEMPHASIS): no CJK in ASCII mode. */
1013
+ #define ISCJK(off) FALSE
1014
+ #define ISCJKBEFORE(off) FALSE
1015
+
968
1016
  static inline void
969
1017
  md_get_unicode_fold_info(unsigned codepoint, MD_UNICODE_FOLD_INFO* info)
970
1018
  {
@@ -3044,6 +3092,8 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
3044
3092
  OFF tmp = off+1;
3045
3093
  int left_level; /* What precedes: 0 = whitespace; 1 = punctuation; 2 = other char. */
3046
3094
  int right_level; /* What follows: 0 = whitespace; 1 = punctuation; 2 = other char. */
3095
+ int left_is_cjk = FALSE;
3096
+ int right_is_cjk = FALSE;
3047
3097
 
3048
3098
  while(tmp < line->end && CH(tmp) == ch)
3049
3099
  tmp++;
@@ -3062,6 +3112,16 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
3062
3112
  else
3063
3113
  right_level = 2;
3064
3114
 
3115
+ /* MARKTIP LOCAL PATCH: a CJK character next to the delimiter
3116
+ * does not block it from being an opener/closer on the other
3117
+ * side (cf. markdown-it-cjk-friendly). */
3118
+ if(ctx->parser.flags & MD_FLAG_CJKFRIENDLYEMPHASIS) {
3119
+ if(off > line->beg)
3120
+ left_is_cjk = ISCJKBEFORE(off);
3121
+ if(tmp < line->end)
3122
+ right_is_cjk = ISCJK(tmp);
3123
+ }
3124
+
3065
3125
  /* Intra-word underscore doesn't have special meaning. */
3066
3126
  if(ch == _T('_') && left_level == 2 && right_level == 2) {
3067
3127
  left_level = 0;
@@ -3071,9 +3131,9 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
3071
3131
  if(left_level != 0 || right_level != 0) {
3072
3132
  unsigned flags = 0;
3073
3133
 
3074
- if(left_level > 0 && left_level >= right_level)
3134
+ if(left_level > 0 && (left_level >= right_level || right_is_cjk))
3075
3135
  flags |= MD_MARK_POTENTIAL_CLOSER;
3076
- if(right_level > 0 && right_level >= left_level)
3136
+ if(right_level > 0 && (right_level >= left_level || left_is_cjk))
3077
3137
  flags |= MD_MARK_POTENTIAL_OPENER;
3078
3138
  if(flags == (MD_MARK_POTENTIAL_OPENER | MD_MARK_POTENTIAL_CLOSER))
3079
3139
  flags |= MD_MARK_EMPH_OC;
@@ -3295,10 +3355,14 @@ md_collect_marks(MD_CTX* ctx, const MD_LINE* lines, MD_SIZE n_lines, int table_m
3295
3355
 
3296
3356
  if(tmp - off <= 2) {
3297
3357
  unsigned flags = MD_MARK_POTENTIAL_OPENER | MD_MARK_POTENTIAL_CLOSER;
3358
+ /* MARKTIP LOCAL PATCH: CJK-friendly strikethrough. */
3359
+ int cjk_friendly = (ch == _T('~') && (ctx->parser.flags & MD_FLAG_CJKFRIENDLYEMPHASIS));
3298
3360
 
3299
- if(off > line->beg && !ISUNICODEWHITESPACEBEFORE(off) && !ISUNICODEPUNCTBEFORE(off))
3361
+ if(off > line->beg && !ISUNICODEWHITESPACEBEFORE(off) && !ISUNICODEPUNCTBEFORE(off)
3362
+ && !(cjk_friendly && ISCJKBEFORE(off)))
3300
3363
  flags &= ~MD_MARK_POTENTIAL_OPENER;
3301
- if(tmp < line->end && !ISUNICODEWHITESPACE(tmp) && !ISUNICODEPUNCT(tmp))
3364
+ if(tmp < line->end && !ISUNICODEWHITESPACE(tmp) && !ISUNICODEPUNCT(tmp)
3365
+ && !(cjk_friendly && ISCJK(tmp)))
3302
3366
  flags &= ~MD_MARK_POTENTIAL_CLOSER;
3303
3367
  if(flags != 0)
3304
3368
  ADD_MARK(ch, off, tmp, flags);
@@ -319,6 +319,11 @@ typedef struct MD_SPAN_WIKILINK {
319
319
  #define MD_FLAG_UNDERLINE 0x4000 /* Enable underline extension (and disables '_' for normal emphasis). */
320
320
  #define MD_FLAG_HARD_SOFT_BREAKS 0x8000 /* Force all soft breaks to act as hard breaks. */
321
321
 
322
+ /* MARKTIP LOCAL PATCH (not upstream): relax emphasis/strikethrough flanking
323
+ * rules so a CJK character next to a delimiter does not prevent it from
324
+ * opening/closing (cf. markdown-it-cjk-friendly). */
325
+ #define MD_FLAG_CJKFRIENDLYEMPHASIS 0x10000
326
+
322
327
  #define MD_FLAG_PERMISSIVEAUTOLINKS (MD_FLAG_PERMISSIVEEMAILAUTOLINKS | MD_FLAG_PERMISSIVEURLAUTOLINKS | MD_FLAG_PERMISSIVEWWWAUTOLINKS)
323
328
  #define MD_FLAG_NOHTML (MD_FLAG_NOHTMLBLOCKS | MD_FLAG_NOHTMLSPANS)
324
329
 
@@ -0,0 +1,21 @@
1
+ # MD4C vendored source
2
+
3
+ This directory vendors the parser-only MD4C 0.5.3 source files:
4
+
5
+ - `src/md4c.c`
6
+ - `src/md4c.h`
7
+ - `src/entity.c` (HTML entity table, used to decode `MD_TEXT_ENTITY`)
8
+ - `src/entity.h`
9
+ - `LICENSE.md`
10
+
11
+ Upstream: https://github.com/mity/md4c
12
+ Pinned tag: `release-0.5.3`
13
+
14
+ ## Local patches (deviations from upstream)
15
+
16
+ - `MD_FLAG_CJKFRIENDLYEMPHASIS` (md4c.h, md4c.c): opt-in flag that relaxes the
17
+ emphasis (`*`, `_`) and strikethrough (`~`) flanking rules so that a CJK
18
+ character next to a delimiter does not prevent it from opening/closing
19
+ (cf. markdown-it-cjk-friendly). Adds `md_is_cjk_char__()` and the
20
+ `ISCJK`/`ISCJKBEFORE` macros. All patched hunks are marked with
21
+ `MARKTIP LOCAL PATCH` comments. Behavior is unchanged unless the flag is set.
@@ -1,58 +0,0 @@
1
- import marktip as tm
2
-
3
-
4
- def assert_stable(markdown: str):
5
- doc = tm.from_markdown(markdown)
6
- serialized = doc.to_markdown()
7
- assert tm.from_markdown(serialized).to_dict() == doc.to_dict()
8
-
9
-
10
- def test_roundtrip_core_blocks_and_marks():
11
- assert_stable(
12
- "# Title\n\n"
13
- "A **bold** and *italic* paragraph with `code` and ~~strike~~.\n\n"
14
- "> quoted\n\n"
15
- "- one\n"
16
- "- two\n\n"
17
- "1. first\n"
18
- "2. second\n"
19
- )
20
-
21
-
22
- def test_roundtrip_gfm_table_task_list_and_code():
23
- assert_stable(
24
- "- [x] done\n"
25
- "- [ ] todo\n\n"
26
- "| A | B |\n"
27
- "| :- | -: |\n"
28
- "| x | y |\n\n"
29
- "```python\n"
30
- "print('ok')\n"
31
- "```\n"
32
- )
33
-
34
-
35
- def test_roundtrip_multi_block_list_items_and_loose_lists():
36
- assert_stable("- first\n\n second\n")
37
- assert_stable("- a\n\n- b\n")
38
-
39
-
40
- def test_roundtrip_hard_break_inside_marks():
41
- assert_stable("**a\\\nb**")
42
- assert_stable("[a\\\nb](https://example.com)")
43
-
44
-
45
- def test_roundtrip_block_markers_escaped_in_text():
46
- assert_stable("\\- not a list")
47
- assert_stable("\\> not a quote")
48
- assert_stable("1\\. not ordered")
49
-
50
-
51
- def test_roundtrip_ordered_task_list():
52
- assert_stable("1. [x] done\n2. [ ] todo\n")
53
-
54
-
55
- def test_roundtrip_large_and_deep_smoke():
56
- nested = "- root\n - child\n - grandchild\n"
57
- large = "\n".join(f"Paragraph {i} with **bold** text." for i in range(200))
58
- assert_stable(nested + "\n" + large)
@@ -1,12 +0,0 @@
1
- # MD4C vendored source
2
-
3
- This directory vendors the parser-only MD4C 0.5.3 source files:
4
-
5
- - `src/md4c.c`
6
- - `src/md4c.h`
7
- - `src/entity.c` (HTML entity table, used to decode `MD_TEXT_ENTITY`)
8
- - `src/entity.h`
9
- - `LICENSE.md`
10
-
11
- Upstream: https://github.com/mity/md4c
12
- Pinned tag: `release-0.5.3`
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes