Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ fn edit_nodes(py: Python<'_>, markdown: &str, math: &str, templates: Option<Vec<
d.set_item("_url_start", url_range.start)?;
d.set_item("_url_end", url_range.end)?;
}
EditNode::Link { range, url_range, url, title } => {
d.set_item("type", "link")?;
d.set_item("form", "inline")?;
d.set_item("source", &markdown[range.clone()])?;
d.set_item("start", range.start)?;
d.set_item("end", range.end)?;
d.set_item("url", url)?;
d.set_item("title", title)?;
d.set_item("_url_start", url_range.start)?;
d.set_item("_url_end", url_range.end)?;
}
EditNode::Math { range, delimiter, tex } => {
d.set_item("type", "math_inline")?;
d.set_item("source", &markdown[range.clone()])?;
Expand Down
4 changes: 2 additions & 2 deletions python/mdhtml/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ def rewrite(markdown: str, callbacks: dict, *, math: str = "brackets") -> str:
edits.append((start, end, replacement))
continue
if not isinstance(replacement, dict): raise TypeError(f"{raw['type']} callback must return None, str, or dict")
allowed = {"url"} if raw["type"] == "image" else {"tex"}
allowed = {"url"} if raw["type"] in ("image", "link") else {"tex"}
unknown = replacement.keys() - allowed
if unknown: raise ValueError(f"unknown {raw['type'].replace('_inline', '')} replacement field: {sorted(unknown)[0]}")
if any(not isinstance(value, str) for value in replacement.values()):
raise TypeError(f"{raw['type']} replacement fields must be strings")
if raw["type"] == "image" and "url" in replacement:
if raw["type"] in ("image", "link") and "url" in replacement:
edits.append((offsets[internal["_url_start"]], offsets[internal["_url_end"]], replacement["url"]))
if raw["type"] == "math_inline" and "tex" in replacement:
n = len(raw["delimiter"])
Expand Down
12 changes: 8 additions & 4 deletions python/mdhtml/md.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ def _is_caption(line):

class _GfmExporter:
def __init__(self, reftypes, number_headings, math, implicit_figures, templates=None, tmpl=None,
raw=("md",), imgdir=None, imgbase=None):
raw=("md",), imgdir=None, imgbase=None, link=None):
self.res = Resolver(reftypes)
self.number_headings, self.math, self.implicit_figures = number_headings, math, implicit_figures
self.templates, self.tmpl = [astuple(t) if is_dataclass(t) else tuple(t) for t in templates or []], tmpl
self.raw, self.imgdir, self.imgbase = raw, imgdir, imgbase
self.link = link
self.warnings, self.inline, self.block, self.rebuilt = [], [], [], []

def run(self, src):
Expand All @@ -86,6 +87,9 @@ def run(self, src):
elif n["type"] == "template_token" and self.tmpl: self.inline.append((n["start"], n["end"], self.tmpl(n)))
elif n["type"] == "image" and self.imgdir and ";base64," in n["url"] and n["url"].startswith("data:"):
self.inline.append((n["_url_start"], n["_url_end"], self._extract_img(n["url"])))
elif self.link is not None and n["type"] in ("image", "link"):
new = self.link(n["url"])
if new is not None: self.inline.append((n["_url_start"], n["_url_end"], new))
for x, parsed in self.xrefs: self._xref(x, parsed)
for b in spans: self._block(b)
keep = [e for e in self.inline if not any(s <= e[0] and e[1] <= t for s, t in self.rebuilt)]
Expand Down Expand Up @@ -235,13 +239,13 @@ def _table(self, b, s, e):
self.block.append((p, p, f"\n{label} {n}\n"))

def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, math: str = "brackets",
implicit_figures: bool = False, templates=None, tmpl=None, raw: tuple = ("md",), imgdir=None) -> Md:
implicit_figures: bool = False, templates=None, tmpl=None, raw: tuple = ("md",), imgdir=None, link=None) -> Md:
"""Lower Markdown to portable GFM-plus-footnotes by rewriting mdhtml-specific constructs in
place: cross-references become plain text, headings and captions are numbered, attribute
lists and definitions are stripped, and raw data in the formats named by `raw` is spliced
(all other formats drop; `('md', 'html')` suits targets that render inline HTML, like GFM).
With `imgdir`, each base64 data-URI image is written to a content-hashed file in that
directory and its src rewritten relative to `dest`'s directory (or the cwd). With `templates`,
directory and its src rewritten relative to `dest`'s directory (or the cwd). With `link`, each inline link or image URL is passed to the callback and replaced when it returns a non-`None` string. With `templates`,
each template token is rewritten to whatever the
`tmpl` callable `(node) -> str` returns: the node dict carries `body`, `syntax`, `form`,
scanner classification (`kind`, `name`, `inverted`), and spans (`mustache_code` is a ready-made recipe;
Expand All @@ -250,7 +254,7 @@ def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, m
normalized, offsets = _normalize_offsets(src)
imgbase = Path(dest).parent if dest is not None else Path(".")
ex = _GfmExporter(reftypes, number_headings, math, implicit_figures, templates, tmpl,
raw=raw, imgdir=None if imgdir is None else Path(imgdir), imgbase=imgbase)
raw=raw, imgdir=None if imgdir is None else Path(imgdir), imgbase=imgbase, link=link)
edits = ex.run(normalized)
for start, end, repl in reversed(edits): src = src[:offsets[start]] + repl + src[offsets[end]:]
res = Md(src, ex.warnings)
Expand Down
2 changes: 1 addition & 1 deletion src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@ fn edit_nodes_for_regions(source: &Source<'_>, regions: &[(usize, usize, RegionK
}
}
out.sort_by_key(|node| match node {
EditNode::Image { range, .. }
EditNode::Image { range, .. } | EditNode::Link { range, .. }
| EditNode::Math { range, .. }
| EditNode::Xref { range, .. }
| EditNode::Attrs { range, .. }
Expand Down
29 changes: 18 additions & 11 deletions src/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ pub(crate) fn inline_events(src: &str, ctx: &InlineContext<'_>) -> Vec<InlineEve
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EditNode {
Image { range: Range<usize>, url_range: Range<usize>, alt: String, url: String, title: Option<String> },
Link { range: Range<usize>, url_range: Range<usize>, url: String, title: Option<String> },
Math { range: Range<usize>, delimiter: &'static str, tex: String },
Xref { range: Range<usize>, refs: Vec<XrefSeg>, tokens: Option<String> },
Attrs { id: Option<String>, range: Range<usize> },
Expand All @@ -73,7 +74,7 @@ pub struct XrefSeg { pub target: String, pub bare: bool, pub prefix: Option<Stri
impl EditNode {
pub fn shift(&mut self, offset: usize) {
match self {
Self::Image { range, url_range, .. } => {
Self::Image { range, url_range, .. } | Self::Link { range, url_range, .. } => {
range.start += offset;
range.end += offset;
url_range.start += offset;
Expand Down Expand Up @@ -153,9 +154,8 @@ pub fn find_edit_nodes(src: &str, ctx: &InlineContext<'_>) -> Vec<EditNode> {
&& let Some((label, label_len)) = scan_link_label(&src[i..])
{
let after = i + label_len;
if starts(src, after, "(")
&& let Some((_, next)) = paren_content(src, after + 1, ctx.options.max_link_paren_depth)
{
if let Some((node, next)) = inline_link_edit_node(src, i, label_len, ctx) {
out.push(node);
i = next + attr_after(src, next, &mut out);
continue;
}
Expand Down Expand Up @@ -211,22 +211,29 @@ fn ref_attr(src: &str, at: usize) -> (Option<String>, usize) {
match trailing_attr(&src[at..]) { Some((attr, n)) => (attr.pairs.iter().find(|(k, _)| k == "ref").map(|(_, v)| v.clone()), n), None => (None, 0) }
}

fn inline_image_edit_node(src: &str, i: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> {
let (alt, label_len) = scan_link_label(&src[i + 1..])?;
let after = i + 1 + label_len;
fn inline_url(src: &str, after: usize, max_parens: usize) -> Option<(Range<usize>, String, Option<String>, usize)> {
if after >= src.len() || !starts(src, after, "(") { return None; }
let max_parens = ctx.options.max_link_paren_depth;
let (inside, next) = paren_content(src, after + 1, max_parens)?;
let trimmed = trim_link_space(inside);
let raw_url = if trimmed.is_empty() { &trim_link_space_start(inside)[..0] } else { parse_link_destination(trimmed, max_parens)?.0 };
let (url, title) = parse_link_destination_title(inside, max_parens)?;
let inside_start = after + 1;
let url_start = inside_start + raw_url.as_ptr() as usize - inside.as_ptr() as usize;
let url_range = url_start..url_start + raw_url.len();
let url_start = after + 1 + raw_url.as_ptr() as usize - inside.as_ptr() as usize;
Some((url_start..url_start + raw_url.len(), url, title, next))
}

fn inline_image_edit_node(src: &str, i: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> {
let (alt, label_len) = scan_link_label(&src[i + 1..])?;
let after = i + 1 + label_len;
let (url_range, url, title, next) = inline_url(src, after, ctx.options.max_link_paren_depth)?;
let alt = crate::render::plain(&parse_inlines(&alt, ctx));
Some((EditNode::Image { range: i..next, url_range, alt, url, title }, next))
}

fn inline_link_edit_node(src: &str, i: usize, label_len: usize, ctx: &InlineContext<'_>) -> Option<(EditNode, usize)> {
let (url_range, url, title, next) = inline_url(src, i + label_len, ctx.options.max_link_paren_depth)?;
Some((EditNode::Link { range: i..next, url_range, url, title }, next))
}

pub fn parse_inlines(src: &str, ctx: &InlineContext<'_>) -> Vec<Inline> { coalesce(parse_inner(src, ctx)) }

fn parse_inner(src: &str, ctx: &InlineContext<'_>) -> Vec<Inline> {
Expand Down
2 changes: 1 addition & 1 deletion src/wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ fn words(src: &str, ctx: &InlineContext<'_>) -> Vec<String> {
.map(|event| event.start..event.end)
.collect();
protected.extend(crate::inline::find_edit_nodes(src, ctx).into_iter().map(|node| match node {
EditNode::Image { range, .. }
EditNode::Image { range, .. } | EditNode::Link { range, .. }
| EditNode::Math { range, .. }
| EditNode::Xref { range, .. }
| EditNode::Attrs { range, .. }
Expand Down
13 changes: 13 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,19 @@ def test_md2gfm_imgdir(tmp_path):
assert files[0].read_bytes() == b64.b64decode(png_b64)
assert dest.read_text() == out


def test_md2gfm_relink():
seen = []
def link(url):
seen.append(url)
return {'docs/a_(b).md#part': 'docs/a_(b).html#part', 'img/plot_(1).png': 'assets/plot.png'}.get(url)
md = ('See [guide](docs/a_(b).md#part "Read") and [web](https://example.com).\n\n'
'![Plot](img/plot_(1).png "Chart") and ![keep](keep.png).\n')
out = md2gfm(md, link=link)
assert out == ('See [guide](docs/a_(b).html#part "Read") and [web](https://example.com).\n\n'
'![Plot](assets/plot.png "Chart") and ![keep](keep.png).\n')
assert seen == ['docs/a_(b).md#part', 'https://example.com', 'img/plot_(1).png', 'keep.png']

def test_md2gfm_passthrough():
md = ('Text[^1] with $x$ math and | pipes |.\n\n[^1]: A note.\n\n'
'| A | B |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n\n[ref link][r]\n\n[r]: /url\n')
Expand Down
11 changes: 11 additions & 0 deletions tests/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,17 @@ def math(node):
dict(type="math_inline", source="$x^2$", start=55, end=60, delimiter="$", display=False, tex="x^2")]


def test_rewrite_link_url():
from mdhtml import rewrite
seen = []
src = 'Before [guide](docs/a_(b).md#part "Read") after.'
got = rewrite(src, {'link': lambda node: seen.append(node) or {'url': 'guide.html#part'}})
assert got == 'Before [guide](guide.html#part "Read") after.'
node, = seen
assert (node['type'], node['form'], node['source'], node['url'], node['title']) == (
'link', 'inline', '[guide](docs/a_(b).md#part "Read")', 'docs/a_(b).md#part', 'Read')


def test_rewrite_skips_code_and_fenced_blocks():
from mdhtml import rewrite
src = "`$code$ ![x](bad)` [label](https://x/$url$) <i data-x='$html$'> and $math$\n\n- before\n ```\n $fenced$ ![x](bad)\n ```\n- ![x](data:x)\n"
Expand Down
Loading