modern-text 0.5.13 → 0.6.1

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.
package/dist/index.mjs CHANGED
@@ -454,6 +454,9 @@ function filterEmpty(val) {
454
454
  }
455
455
  return res;
456
456
  }
457
+ function needsFetch(source) {
458
+ return source.startsWith("http://") || source.startsWith("https://") || source.startsWith("blob://");
459
+ }
457
460
 
458
461
  var __defProp$3 = Object.defineProperty;
459
462
  var __defNormalProp$3 = (obj, key, value) => key in obj ? __defProp$3(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
@@ -524,9 +527,6 @@ function definePlugin(options) {
524
527
  }
525
528
 
526
529
  class Measurer {
527
- constructor(_text) {
528
- this._text = _text;
529
- }
530
530
  _styleToDomStyle(style) {
531
531
  const _style = { ...style };
532
532
  for (const key in style) {
@@ -558,8 +558,7 @@ class Measurer {
558
558
  * </ul>
559
559
  * </section>
560
560
  */
561
- createDom() {
562
- const { paragraphs, computedStyle } = this._text;
561
+ createParagraphDom(paragraphs, rootStyle) {
563
562
  const documentFragment = document.createDocumentFragment();
564
563
  const dom = document.createElement("section");
565
564
  Object.assign(dom.style, {
@@ -567,7 +566,7 @@ class Measurer {
567
566
  height: "max-content",
568
567
  whiteSpace: "pre-wrap",
569
568
  wordBreak: "break-all",
570
- ...this._styleToDomStyle(computedStyle),
569
+ ...this._styleToDomStyle(rootStyle),
571
570
  position: "fixed",
572
571
  visibility: "hidden"
573
572
  });
@@ -596,12 +595,41 @@ class Measurer {
596
595
  destory: () => dom.parentNode?.removeChild(dom)
597
596
  };
598
597
  }
599
- _measureDom(dom) {
598
+ measureDomText(text) {
599
+ const range = document.createRange();
600
+ range.selectNodeContents(text);
601
+ const data = text.data ?? "";
602
+ let offset = 0;
603
+ return Array.from(data).map((char) => {
604
+ const start = offset += data.substring(offset).indexOf(char);
605
+ const end = start + char.length;
606
+ offset += char.length;
607
+ range.setStart(text, Math.max(start, 0));
608
+ range.setEnd(text, end);
609
+ const rects = range.getClientRects?.() ?? [range.getBoundingClientRect()];
610
+ let rect = rects[rects.length - 1];
611
+ if (rects.length > 1 && rect.width < 2) {
612
+ rect = rects[rects.length - 2];
613
+ }
614
+ const content = range.toString();
615
+ if (content !== "" && rect && rect.width + rect.height !== 0) {
616
+ return {
617
+ content,
618
+ top: rect.top,
619
+ left: rect.left,
620
+ height: rect.height,
621
+ width: rect.width
622
+ };
623
+ }
624
+ return void 0;
625
+ }).filter(Boolean);
626
+ }
627
+ measureDom(dom) {
600
628
  const paragraphs = [];
601
629
  const fragments = [];
602
630
  const characters = [];
603
- dom.querySelectorAll("li").forEach((li, paragraphIndex) => {
604
- const pBox = li.getBoundingClientRect();
631
+ dom.querySelectorAll("li").forEach((pDom, paragraphIndex) => {
632
+ const pBox = pDom.getBoundingClientRect();
605
633
  paragraphs.push({
606
634
  paragraphIndex,
607
635
  left: pBox.left,
@@ -609,8 +637,8 @@ class Measurer {
609
637
  width: pBox.width,
610
638
  height: pBox.height
611
639
  });
612
- li.querySelectorAll("span").forEach((span, fragmentIndex) => {
613
- const fBox = span.getBoundingClientRect();
640
+ pDom.querySelectorAll(":scope > *").forEach((fDom, fragmentIndex) => {
641
+ const fBox = fDom.getBoundingClientRect();
614
642
  fragments.push({
615
643
  paragraphIndex,
616
644
  fragmentIndex,
@@ -619,37 +647,32 @@ class Measurer {
619
647
  width: fBox.width,
620
648
  height: fBox.height
621
649
  });
622
- const text = span.firstChild;
623
- if (text instanceof window.Text) {
624
- const range = document.createRange();
625
- range.selectNodeContents(text);
626
- const data = text.data ?? "";
627
- let offset = 0;
628
- Array.from(data).forEach((char, index) => {
629
- const start = offset += data.substring(offset).indexOf(char);
630
- const end = start + char.length;
631
- offset += char.length;
632
- range.setStart(text, Math.max(start, 0));
633
- range.setEnd(text, end);
634
- const rects = range.getClientRects?.() ?? [range.getBoundingClientRect()];
635
- let rect = rects[rects.length - 1];
636
- if (rects.length > 1 && rect.width < 2) {
637
- rect = rects[rects.length - 2];
638
- }
639
- const content = range.toString();
640
- if (content !== "" && rect && rect.width + rect.height !== 0) {
641
- characters.push({
642
- content,
643
- newParagraphIndex: -1,
644
- paragraphIndex,
645
- fragmentIndex,
646
- characterIndex: index,
647
- top: rect.top,
648
- left: rect.left,
649
- height: rect.height,
650
- width: rect.width,
651
- textWidth: -1,
652
- textHeight: -1
650
+ let characterIndex = 0;
651
+ if (fDom.firstChild instanceof window.Text) {
652
+ this.measureDomText(fDom.firstChild).forEach((char) => {
653
+ characters.push({
654
+ ...char,
655
+ newParagraphIndex: -1,
656
+ paragraphIndex,
657
+ fragmentIndex,
658
+ characterIndex: characterIndex++,
659
+ textWidth: -1,
660
+ textHeight: -1
661
+ });
662
+ });
663
+ } else {
664
+ fDom.querySelectorAll(":scope > *").forEach((cDom) => {
665
+ if (cDom.firstChild instanceof window.Text) {
666
+ this.measureDomText(cDom.firstChild).forEach((char) => {
667
+ characters.push({
668
+ ...char,
669
+ newParagraphIndex: -1,
670
+ paragraphIndex,
671
+ fragmentIndex,
672
+ characterIndex: characterIndex++,
673
+ textWidth: -1,
674
+ textHeight: -1
675
+ });
653
676
  });
654
677
  }
655
678
  });
@@ -662,10 +685,9 @@ class Measurer {
662
685
  characters
663
686
  };
664
687
  }
665
- measureDom(dom) {
666
- const { paragraphs } = this._text;
688
+ measureParagraphDom(paragraphs, dom) {
667
689
  const rect = dom.getBoundingClientRect();
668
- const measured = this._measureDom(dom);
690
+ const measured = this.measureDom(dom);
669
691
  measured.paragraphs.forEach((p) => {
670
692
  const _p = paragraphs[p.paragraphIndex];
671
693
  _p.lineBox.left = p.left - rect.left;
@@ -715,12 +737,12 @@ class Measurer {
715
737
  boundingBox: new BoundingBox(0, 0, rect.width, rect.height)
716
738
  };
717
739
  }
718
- measure(dom) {
740
+ measure(paragraphs, rootStyle, dom) {
719
741
  let destory;
720
742
  if (!dom) {
721
- ({ dom, destory } = this.createDom());
743
+ ({ dom, destory } = this.createParagraphDom(paragraphs, rootStyle));
722
744
  }
723
- const result = this.measureDom(dom);
745
+ const result = this.measureParagraphDom(paragraphs, dom);
724
746
  destory?.();
725
747
  return result;
726
748
  }
@@ -823,33 +845,74 @@ class EventEmitter {
823
845
  function highlight() {
824
846
  const paths = [];
825
847
  const clipRects = [];
826
- const svgStringToSvgPaths = /* @__PURE__ */ new Map();
827
- async function getPaths(svg) {
828
- let result = svgStringToSvgPaths.get(svg);
829
- if (!result) {
830
- if (svg.startsWith("http")) {
831
- svg = await fetch(svg).then((rep) => rep.text());
848
+ const loaded = /* @__PURE__ */ new Map();
849
+ const parsed = /* @__PURE__ */ new Map();
850
+ async function loadSvg(svg) {
851
+ if (!loaded.has(svg)) {
852
+ loaded.set(svg, svg);
853
+ try {
854
+ loaded.set(svg, await fetch(svg).then((rep) => rep.text()));
855
+ } catch (err) {
856
+ console.warn(err);
857
+ loaded.delete(svg);
832
858
  }
833
- const dom = parseSvgToDom(svg);
859
+ }
860
+ }
861
+ function getPaths(svg) {
862
+ let result = parsed.get(svg);
863
+ if (!result) {
864
+ const dom = parseSvgToDom(
865
+ needsFetch(svg) ? loaded.get(svg) ?? svg : svg
866
+ );
834
867
  const paths2 = parseSvg(dom);
835
868
  result = { dom, paths: paths2 };
836
- svgStringToSvgPaths.set(svg, result);
869
+ parsed.set(svg, result);
837
870
  }
838
871
  return result;
839
872
  }
840
873
  return definePlugin({
841
874
  name: "highlight",
842
875
  paths,
843
- update: async (text) => {
876
+ load: async (text) => {
877
+ const promises = [];
878
+ text.forEachCharacter((character) => {
879
+ const { computedStyle: style } = character;
880
+ const { highlightImage, highlightReferImage } = style;
881
+ if (needsFetch(highlightImage)) {
882
+ promises.push(loadSvg(highlightImage));
883
+ }
884
+ if (needsFetch(highlightReferImage)) {
885
+ promises.push(loadSvg(highlightReferImage));
886
+ }
887
+ });
888
+ await Promise.all(promises);
889
+ },
890
+ update: (text) => {
844
891
  clipRects.length = 0;
845
892
  paths.length = 0;
846
893
  let groups = [];
847
894
  let group;
848
895
  let prevStyle;
849
896
  text.forEachCharacter((character) => {
850
- const { isVertical, computedStyle: style, inlineBox } = character;
851
- if (!isNone(style.highlightImage) && character.glyphBox) {
852
- if ((!prevStyle || isEqualValue(prevStyle.highlightImage, style.highlightImage) && isEqualValue(prevStyle.highlightColormap, style.highlightColormap) && isEqualValue(prevStyle.highlightLine, style.highlightLine) && isEqualValue(prevStyle.highlightSize, style.highlightSize) && isEqualValue(prevStyle.highlightThickness, style.highlightThickness)) && group?.length && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top) && group[0].fontSize === style.fontSize) {
897
+ const {
898
+ computedStyle: style
899
+ } = character;
900
+ const {
901
+ highlightImage
902
+ } = style;
903
+ if (!isNone(highlightImage)) {
904
+ const {
905
+ inlineBox,
906
+ isVertical
907
+ } = character;
908
+ const {
909
+ fontSize,
910
+ highlightColormap,
911
+ highlightLine,
912
+ highlightSize,
913
+ highlightThickness
914
+ } = style;
915
+ if ((!prevStyle || isEqualValue(prevStyle.highlightImage, highlightImage) && isEqualValue(prevStyle.highlightColormap, highlightColormap) && isEqualValue(prevStyle.highlightLine, highlightLine) && isEqualValue(prevStyle.highlightSize, highlightSize) && isEqualValue(prevStyle.highlightThickness, highlightThickness)) && group?.length && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top) && group[0].fontSize === fontSize) {
853
916
  group.push(character);
854
917
  } else {
855
918
  group = [];
@@ -868,8 +931,12 @@ function highlight() {
868
931
  for (let i = 0; i < groups.length; i++) {
869
932
  const characters = groups[i];
870
933
  const char = characters[0];
871
- const groupBox = BoundingBox.from(...characters.map((c) => c.glyphBox));
872
- const { computedStyle: style } = char;
934
+ const groupBox = BoundingBox.from(
935
+ ...characters.filter((c) => c.glyphBox).map((c) => c.glyphBox)
936
+ );
937
+ const {
938
+ computedStyle: style
939
+ } = char;
873
940
  const {
874
941
  fontSize,
875
942
  writingMode,
@@ -883,7 +950,7 @@ function highlight() {
883
950
  const isVertical = writingMode.includes("vertical");
884
951
  const thickness = parseValueNumber(highlightThickness, { fontSize, total: groupBox.width }) / groupBox.width;
885
952
  const colormap = parseColormap(highlightColormap);
886
- const { paths: svgPaths, dom: svgDom } = await getPaths(highlightImage);
953
+ const { paths: svgPaths, dom: svgDom } = getPaths(highlightImage);
887
954
  const aBox = getPathsBoundingBox(svgPaths, true);
888
955
  const styleScale = fontSize / aBox.width * 2;
889
956
  const cBox = new BoundingBox().copy(groupBox);
@@ -899,7 +966,7 @@ function highlight() {
899
966
  cBox.width = userWidth;
900
967
  }
901
968
  if (!isNone(highlightReferImage) && isNone(highlightLine)) {
902
- const bBox = getPathsBoundingBox((await getPaths(highlightReferImage)).paths, true);
969
+ const bBox = getPathsBoundingBox(getPaths(highlightReferImage).paths, true);
903
970
  aBox.copy(bBox);
904
971
  } else {
905
972
  let line;
@@ -1050,8 +1117,16 @@ function listStyle() {
1050
1117
  const { paragraphs, isVertical, fontSize } = text;
1051
1118
  const padding = fontSize * 0.45;
1052
1119
  paragraphs.forEach((paragraph) => {
1053
- const { computedStyle: style } = paragraph;
1054
- const { listStyleImage, listStyleColormap, listStyleSize, listStyleType, color } = style;
1120
+ const {
1121
+ computedStyle: style
1122
+ } = paragraph;
1123
+ const {
1124
+ color,
1125
+ listStyleImage,
1126
+ listStyleColormap,
1127
+ listStyleSize,
1128
+ listStyleType
1129
+ } = style;
1055
1130
  const colormap = parseColormap(listStyleColormap);
1056
1131
  let size = listStyleSize;
1057
1132
  let image;
@@ -1080,18 +1155,10 @@ function listStyle() {
1080
1155
  const m = new Matrix3();
1081
1156
  if (isVertical) {
1082
1157
  const reScale = fontSize / imageBox.height * scale;
1083
- m.translate(-imageBox.left, -imageBox.top);
1084
- m.rotate(Math.PI / 2);
1085
- m.scale(reScale, reScale);
1086
- m.translate(fontSize / 2 - imageBox.height * reScale / 2, 0);
1087
- m.translate(box.left + (box.width - fontSize) / 2, fBox.top - padding);
1158
+ m.translate(-imageBox.left, -imageBox.top).rotate(Math.PI / 2).scale(reScale, reScale).translate(fontSize / 2 - imageBox.height * reScale / 2, 0).translate(box.left + (box.width - fontSize) / 2, fBox.top - padding);
1088
1159
  } else {
1089
1160
  const reScale = fontSize / imageBox.height * scale;
1090
- m.translate(-imageBox.left, -imageBox.top);
1091
- m.translate(-imageBox.width, 0);
1092
- m.scale(reScale, reScale);
1093
- m.translate(0, fontSize / 2 - imageBox.height * reScale / 2);
1094
- m.translate(fBox.left - padding, box.top + (box.height - fontSize) / 2);
1161
+ m.translate(-imageBox.left, -imageBox.top).translate(-imageBox.width, 0).scale(reScale, reScale).translate(0, fontSize / 2 - imageBox.height * reScale / 2).translate(fBox.left - padding, box.top + (box.height - fontSize) / 2);
1095
1162
  }
1096
1163
  paths.push(...imagePaths.map((p) => {
1097
1164
  const path = p.clone();
@@ -1227,11 +1294,24 @@ function textDecoration() {
1227
1294
  let group;
1228
1295
  let prevStyle;
1229
1296
  text.forEachCharacter((character) => {
1230
- const { computedStyle: style, isVertical, inlineBox, underlinePosition, underlineThickness, strikeoutPosition, strikeoutSize } = character;
1231
- if (!isNone(style.textDecoration)) {
1297
+ const {
1298
+ computedStyle: style,
1299
+ isVertical,
1300
+ inlineBox,
1301
+ underlinePosition,
1302
+ underlineThickness,
1303
+ strikeoutPosition,
1304
+ strikeoutSize
1305
+ } = character;
1306
+ const {
1307
+ color,
1308
+ textDecoration: textDecoration2,
1309
+ writingMode
1310
+ } = style;
1311
+ if (!isNone(textDecoration2)) {
1232
1312
  let flag = false;
1233
- if (prevStyle?.textDecoration === style.textDecoration && prevStyle?.writingMode === style.writingMode && prevStyle?.color === style.color && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top)) {
1234
- switch (style.textDecoration) {
1313
+ if (prevStyle?.textDecoration === textDecoration2 && prevStyle?.writingMode === writingMode && prevStyle?.color === color && (isVertical ? group[0].inlineBox.left === inlineBox.left : group[0].inlineBox.top === inlineBox.top)) {
1314
+ switch (textDecoration2) {
1235
1315
  case "underline":
1236
1316
  if (group[0].underlinePosition === underlinePosition && group[0].underlineThickness === underlineThickness) {
1237
1317
  flag = true;
@@ -1257,8 +1337,18 @@ function textDecoration() {
1257
1337
  }
1258
1338
  });
1259
1339
  groups.forEach((group2) => {
1260
- const { computedStyle: style, isVertical, underlinePosition, underlineThickness, strikeoutPosition, strikeoutSize } = group2[0];
1261
- const { textDecoration: textDecoration2, color } = style;
1340
+ const {
1341
+ computedStyle: style,
1342
+ isVertical,
1343
+ underlinePosition,
1344
+ underlineThickness,
1345
+ strikeoutPosition,
1346
+ strikeoutSize
1347
+ } = group2[0];
1348
+ const {
1349
+ color,
1350
+ textDecoration: textDecoration2
1351
+ } = style;
1262
1352
  const { left, top, width, height } = BoundingBox.from(...group2.map((c) => c.inlineBox));
1263
1353
  let position = isVertical ? left + width : top;
1264
1354
  const direction = isVertical ? -1 : 1;
@@ -1401,7 +1491,7 @@ class Text extends EventEmitter {
1401
1491
  __publicField(this, "glyphBox", new BoundingBox());
1402
1492
  __publicField(this, "pathBox", new BoundingBox());
1403
1493
  __publicField(this, "boundingBox", new BoundingBox());
1404
- __publicField(this, "measurer", new Measurer(this));
1494
+ __publicField(this, "measurer", new Measurer());
1405
1495
  __publicField(this, "plugins", /* @__PURE__ */ new Map());
1406
1496
  __publicField(this, "fonts");
1407
1497
  this.debug = options.debug ?? false;
@@ -1435,6 +1525,9 @@ class Text extends EventEmitter {
1435
1525
  });
1436
1526
  return this;
1437
1527
  }
1528
+ async load() {
1529
+ await Promise.all(Array.from(this.plugins.values()).map((p) => p.load?.(this)));
1530
+ }
1438
1531
  updateParagraphs() {
1439
1532
  this.computedStyle = { ...defaultTextStyles, ...this.style };
1440
1533
  let { content, computedStyle: style } = this;
@@ -1486,7 +1579,7 @@ class Text extends EventEmitter {
1486
1579
  this.paragraphs = paragraphs;
1487
1580
  return this;
1488
1581
  }
1489
- async measure(dom = this.measureDom) {
1582
+ measure(dom = this.measureDom) {
1490
1583
  const old = {
1491
1584
  paragraphs: this.paragraphs,
1492
1585
  lineBox: this.lineBox,
@@ -1496,18 +1589,16 @@ class Text extends EventEmitter {
1496
1589
  boundingBox: this.boundingBox
1497
1590
  };
1498
1591
  this.updateParagraphs();
1499
- const result = this.measurer.measure(dom);
1592
+ const result = this.measurer.measure(this.paragraphs, this.computedStyle, dom);
1500
1593
  this.paragraphs = result.paragraphs;
1501
1594
  this.lineBox = result.boundingBox;
1502
1595
  this.characters.forEach((c) => {
1503
1596
  c.update(this.fonts);
1504
1597
  });
1505
1598
  this.rawGlyphBox = this.getGlyphBox();
1506
- const plugins = Array.from(this.plugins.values());
1507
- plugins.sort((a, b) => (a.updateOrder ?? 0) - (b.updateOrder ?? 0));
1508
- for (let i = 0; i < plugins.length; i++) {
1509
- await plugins[i].update?.(this);
1510
- }
1599
+ Array.from(this.plugins.values()).sort((a, b) => (a.updateOrder ?? 0) - (b.updateOrder ?? 0)).forEach((plugin) => {
1600
+ plugin.update?.(this);
1601
+ });
1511
1602
  this.glyphBox = this.getGlyphBox();
1512
1603
  this.updatePathBox().updateBoundingBox();
1513
1604
  for (const key in old) {
@@ -1563,30 +1654,28 @@ class Text extends EventEmitter {
1563
1654
  this.needsUpdate = true;
1564
1655
  return this;
1565
1656
  }
1566
- async update() {
1567
- const result = await this.measure();
1657
+ update() {
1658
+ const result = this.measure();
1568
1659
  for (const key in result) {
1569
1660
  this[key] = result[key];
1570
1661
  }
1571
1662
  this.emit("update", { text: this });
1663
+ return this;
1572
1664
  }
1573
- async render(options) {
1665
+ render(options) {
1574
1666
  const { view, pixelRatio = 2 } = options;
1575
1667
  const ctx = view.getContext("2d");
1576
1668
  if (!ctx) {
1577
1669
  return;
1578
1670
  }
1579
1671
  if (this.needsUpdate) {
1580
- await this.update();
1672
+ this.update();
1581
1673
  }
1582
1674
  setupView(ctx, pixelRatio, this.boundingBox);
1583
1675
  uploadColors(ctx, this);
1584
- const plugins = Array.from(this.plugins.values());
1585
- plugins.sort((a, b) => (a.renderOrder ?? 0) - (b.renderOrder ?? 0));
1586
- for (let i = 0; i < plugins.length; i++) {
1587
- const plugin = plugins[i];
1676
+ Array.from(this.plugins.values()).sort((a, b) => (a.renderOrder ?? 0) - (b.renderOrder ?? 0)).forEach((plugin) => {
1588
1677
  if (plugin.render) {
1589
- await plugin.render?.(ctx, this);
1678
+ plugin.render?.(ctx, this);
1590
1679
  } else if (plugin.paths) {
1591
1680
  const style = this.computedStyle;
1592
1681
  plugin.paths.forEach((path) => {
@@ -1597,17 +1686,21 @@ class Text extends EventEmitter {
1597
1686
  });
1598
1687
  });
1599
1688
  }
1600
- }
1689
+ });
1601
1690
  this.emit("render", { text: this, view, pixelRatio });
1602
1691
  }
1603
1692
  }
1604
1693
 
1605
- function measureText(options) {
1606
- return new Text(options).measure();
1694
+ async function measureText(options) {
1695
+ const text = new Text(options);
1696
+ await text.load();
1697
+ return text.measure();
1607
1698
  }
1608
1699
 
1609
- function renderText(options) {
1610
- return new Text(options).render(options);
1700
+ async function renderText(options) {
1701
+ const text = new Text(options);
1702
+ await text.load();
1703
+ text.render(options);
1611
1704
  }
1612
1705
 
1613
- export { Character, Fragment, Measurer, Paragraph, Text, defaultTextStyles, definePlugin, drawPath, filterEmpty, getTransform2D, hexToRgb, highlight, isEqualObject, isEqualValue, isNone, listStyle, measureText, parseColor, parseColormap, parseValueNumber, render, renderText, setupView, textDecoration, uploadColor, uploadColors };
1706
+ export { Character, Fragment, Measurer, Paragraph, Text, defaultTextStyles, definePlugin, drawPath, filterEmpty, getTransform2D, hexToRgb, highlight, isEqualObject, isEqualValue, isNone, listStyle, measureText, needsFetch, parseColor, parseColormap, parseValueNumber, render, renderText, setupView, textDecoration, uploadColor, uploadColors };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "modern-text",
3
3
  "type": "module",
4
- "version": "0.5.13",
4
+ "version": "0.6.1",
5
5
  "packageManager": "pnpm@9.9.0",
6
6
  "description": "Measure and render text in a way that describes the DOM.",
7
7
  "author": "wxm",