def__init__(self,text:str)->None:"""Construct a line index from source text. Args: text: Raw input text. """lines=text.splitlines(keepends=True)ifnotlines:lines=[""]self.total_lines=len(lines)self.offsets:list[int]=[0]forlineinlines[:-1]:self.offsets.append(self.offsets[-1]+len(line))
defchar_to_line(self,char_pos:int)->int:"""Map a 0-indexed character position to a 1-indexed line number. Args: char_pos: Character position in source text. Returns: 1-indexed line number clamped to ``[1, total_lines]``. """idx=bisect.bisect_right(self.offsets,char_pos)-1returnmax(1,min(self.total_lines,idx+1))
defspan_to_lines(self,start_char:int,end_char:int)->tuple[int,int]:"""Map a half-open character range ``[start_char, end_char)`` to 1-indexed line bounds. Args: start_char: 0-indexed inclusive start character position. end_char: 0-indexed exclusive end character position. Returns: Tuple of ``(line_start, line_end)`` where ``line_start <= line_end``. """ifstart_char>end_char:start_char,end_char=end_char,start_charl_start=self.char_to_line(start_char)l_end=self.char_to_line(max(start_char,end_char-1))returnl_start,max(l_start,l_end)
def_extract_leading_frontmatter(markdown:str)->tuple[str|None,str]:"""Extract leading YAML frontmatter block if present. Returns: A tuple of (frontmatter_block, remaining_markdown). If no frontmatter is found, returns (None, markdown). """m=_FRONTMATTER_RE.match(markdown)ifm:fm_block=f"---\n{m.group(1).strip()}\n---"remaining=markdown[m.end():].lstrip("\r\n")returnfm_block,remainingreturnNone,markdown
def_get_effective_len(text:str,masks:dict[str,str]|None)->int:"""Calculate the real character count of a text fragment including unmasked content."""ifnotmasks:returnlen(text)length=len(text)fork,vinmasks.items():ifkintext:length=length-len(k)+len(v)returnlength
def_mask_all_blocks(text:str)->tuple[str,dict[str,str]]:"""Mask both code fences and semantic figure blocks. Args: text: Input Markdown text. Returns: A 2-tuple of ``(masked_text, combined_masks)``. """code_masked,code_masks=_mask_code_blocks(text)fig_masked,fig_masks=_mask_semantic_blocks(code_masked)returnfig_masked,{**code_masks,**fig_masks}
def_mask_code_blocks(text:str)->tuple[str,dict[str,str]]:"""Replace fenced code blocks with opaque placeholders. Prevents ``#``-prefixed lines inside code fences from being matched by the ATX header regex during splitting. Args: text: Raw Markdown string that may contain fenced code blocks. Returns: A 2-tuple of ``(masked_text, masks)`` where ``masked_text`` has each fenced block replaced by a unique ``__CODE_BLOCK_N__`` token, and ``masks`` maps each token back to the original block content. """masks={}defreplacer(m:re.Match)->str:"""Replace matched code fence with placeholder token. Args: m: Regex match object representing the code fence. Returns: Placeholder string token. """key=f"__CODE_BLOCK_{len(masks)}__"masks[key]=m.group(0)returnkeymasked=_FENCE_RE.sub(replacer,text)returnmasked,masks
def_mask_semantic_blocks(text:str)->tuple[str,dict[str,str]]:"""Replace <figure>...</figure> blocks with opaque placeholders. Prevents figure/table contents, newlines, and headers from being fragmented across chunks. Args: text: Raw Markdown string containing semantic <figure> blocks. Returns: A 2-tuple of ``(masked_text, masks)`` with placeholders. """masks={}defreplacer(m:re.Match)->str:"""Replace matched semantic figure block with placeholder token. Args: m: Regex match object representing the figure block. Returns: Placeholder string token. """key=f"__SEMANTIC_FIGURE_{len(masks)}__"masks[key]=m.group(0)returnkeymasked=_FIGURE_RE.sub(replacer,text)returnmasked,masks
def_merge_splits(parts:list[str],max_chars:int,overlap:int,sep:str,masks:dict[str,str]|None=None,)->list[str]:""" Greedily merge a list of text fragments into chunks up to max_chars. Accumulates parts until the next part would exceed the limit, then emits the current chunk and carries forward an overlap window of trailing parts. Args: parts: Pre-split text fragments to merge. max_chars: Maximum character count per merged chunk. overlap: Overlap character budget carried between consecutive chunks. sep: Separator string used when joining parts (e.g. ``"\\n\\n"`` or ``" "``). masks: Optional dict mapping placeholder tokens to original block content. Returns: List of merged text chunks. """result:list[str]=[]current_parts:list[str]=[]current_len=0forpartinparts:part_len=_get_effective_len(part,masks)sep_len=len(sep)ifcurrent_partselse0# If a single part exceeds max_charsifpart_len>max_chars:# If it's a masked atomic block (e.g. __SEMANTIC_FIGURE_N__ or __CODE_BLOCK_N__),# keep it intact as its own standalone chunk instead of slicing itifmasksandany(part==kforkinmasks):ifcurrent_parts:result.append(sep.join(current_parts))current_parts=[]current_len=0result.append(part)continueifcurrent_parts:result.append(sep.join(current_parts))current_parts=[]current_len=0result.extend(_recursive_split(part,max_chars,overlap,masks=masks))continueifcurrent_len+sep_len+part_len>max_charsandcurrent_parts:result.append(sep.join(current_parts))overlap_parts:list[str]=[]overlap_len=0forprevinreversed(current_parts):prev_len=_get_effective_len(prev,masks)ifoverlap_len+len(sep)+prev_len<=overlap:overlap_parts.insert(0,prev)overlap_len+=len(sep)+prev_lenelse:breakcurrent_parts=overlap_partscurrent_len=sum(_get_effective_len(p,masks)forpincurrent_parts)+(len(sep)*(len(current_parts)-1)ifcurrent_partselse0)current_parts.append(part)current_len+=sep_len+part_lenifcurrent_parts:result.append(sep.join(current_parts))returnresult
def_normalize_image_path(raw_path:str)->str:"""Normalize local file URIs, relative paths, or filenames to standard relative paths."""ifnotraw_path:return""clean=raw_path.strip()ifclean.startswith("file://"):parsed=urlparse(clean)path_str=unquote(parsed.path)if"images/"inpath_str:idx=path_str.find("images/")returnpath_str[idx:]returnf"images/{Path(path_str).name}"if"images/"inclean:idx=clean.find("images/")returnclean[idx:]returnclean
Recursively split text that is too large for a single chunk.
Applies a waterfall of splitting strategies: paragraph boundaries first
(``
), then sentence boundaries ([.!?] + whitespace``), then hard
character slices as a last resort.
Preserves code blocks and semantic <figure> blocks as indivisible units.
Args:
text: Input text to split.
max_chars: Maximum character count per output chunk.
overlap: Number of characters repeated at the start of each subsequent
chunk to preserve cross-boundary context.
masks: Optional dict mapping placeholder tokens to original block content.
Returns:
List of text chunks, each at most ``max_chars`` characters long.
Raises:
ValueError: If ``overlap`` is greater than or equal to ``max_chars``.
def_recursive_split(text:str,max_chars:int,overlap:int,masks:dict[str,str]|None=None)->list[str]:""" Recursively split text that is too large for a single chunk. Applies a waterfall of splitting strategies: paragraph boundaries first (``\n\n``), then sentence boundaries (``[.!?] + whitespace``), then hard character slices as a last resort. Preserves code blocks and semantic <figure> blocks as indivisible units. Args: text: Input text to split. max_chars: Maximum character count per output chunk. overlap: Number of characters repeated at the start of each subsequent chunk to preserve cross-boundary context. masks: Optional dict mapping placeholder tokens to original block content. Returns: List of text chunks, each at most ``max_chars`` characters long. Raises: ValueError: If ``overlap`` is greater than or equal to ``max_chars``. """ifoverlap>=max_chars:raiseValueError(f"overlap ({overlap}) must be less than max_chars ({max_chars})")ifmasksisNone:masked_text,combined_masks=_mask_all_blocks(text)else:masked_text=textcombined_masks=maskseffective_len=_get_effective_len(masked_text,combined_masks)ifeffective_len<=max_charsandnotcombined_masks:return[text]ifeffective_len<=max_chars:return[_unmask_all_blocks(masked_text,combined_masks)]paragraphs=[p.strip()forpin_PARAGRAPH_SEP.split(masked_text)ifp.strip()]iflen(paragraphs)>1:merged=_merge_splits(paragraphs,max_chars,overlap,"\n\n",combined_masks)return[_unmask_all_blocks(c,combined_masks)forcinmerged]sentences=[s.strip()forsin_SENTENCE_END.split(masked_text)ifs.strip()]iflen(sentences)>1:merged=_merge_splits(sentences,max_chars,overlap," ",combined_masks)return[_unmask_all_blocks(c,combined_masks)forcinmerged]chunks:list[str]=[]start=0whilestart<len(masked_text):end=start+max_charschunk_slice=masked_text[start:end]chunks.append(_unmask_all_blocks(chunk_slice,combined_masks))start=end-overlapifoverlapelseendreturnchunks
Split Markdown text into sections by ATX headers up to level 3.
Masks code and semantic blocks before scanning so that #-prefixed lines inside
fences are not treated as section boundaries. Preamble text before the
first header is included as a section with an empty heading path.
Parameters:
Name
Type
Description
Default
text
str
Raw Markdown string to split.
required
Returns:
Type
Description
list[tuple[list[str], str]]
List of (heading_path, content) pairs where heading_path is
list[tuple[list[str], str]]
an ordered list of ancestor header titles and content is the
def_split_by_headers(text:str)->list[tuple[list[str],str]]:""" Split Markdown text into sections by ATX headers up to level 3. Masks code and semantic blocks before scanning so that ``#``-prefixed lines inside fences are not treated as section boundaries. Preamble text before the first header is included as a section with an empty heading path. Args: text: Raw Markdown string to split. Returns: List of ``(heading_path, content)`` pairs where ``heading_path`` is an ordered list of ancestor header titles and ``content`` is the stripped body text for that section. """masked_text,masks=_mask_all_blocks(text)matches=list(_HEADER_RE.finditer(masked_text))ifnotmatches:return[([],text.strip())]sections:list[tuple[list[str],str]]=[]heading_stack:list[tuple[int,str]]=[]# (level, title)fori,matchinenumerate(matches):level=len(match.group(1))title=match.group(2).strip()start=match.end()end=matches[i+1].start()ifi+1<len(matches)elselen(masked_text)body=masked_text[start:end].strip()whileheading_stackandheading_stack[-1][0]>=level:heading_stack.pop()heading_stack.append((level,title))path=[hfor_,hinheading_stack]ifbody:sections.append((path,_unmask_all_blocks(body,masks)))# Preamble before the first headerpreamble=masked_text[:matches[0].start()].strip()ifpreamble:sections.insert(0,([],_unmask_all_blocks(preamble,masks)))returnsections
def_unmask_all_blocks(text:str,masks:dict[str,str])->str:"""Unmask both code fences and semantic figure blocks. Args: text: Masked Markdown text. masks: Combined dictionary of placeholders and original text. Returns: Unmasked Markdown text. """# Unmask in reverse of masking order (semantic figures first, then code fences)forkey,originalinreversed(list(masks.items())):text=text.replace(key,original)returntext
def_unmask_code_blocks(text:str,masks:dict[str,str])->str:"""Restore fenced code blocks from their placeholder tokens. Args: text: Text containing ``__CODE_BLOCK_N__`` placeholder tokens. masks: Mapping returned by ``_mask_code_blocks`` that associates each token with its original code block content. Returns: Text with all placeholder tokens replaced by their original content. """forkey,originalinmasks.items():text=text.replace(key,original)returntext
def_unmask_semantic_blocks(text:str,masks:dict[str,str])->str:"""Restore <figure>...</figure> blocks from their placeholder tokens. Args: text: Text containing ``__SEMANTIC_FIGURE_N__`` placeholder tokens. masks: Mapping returned by ``_mask_semantic_blocks``. Returns: Text with all placeholder tokens replaced by original content. """forkey,originalinmasks.items():text=text.replace(key,original)returntext
Chunk a Markdown document into semantically coherent pieces.
Splits by ATX headers (H1–H3) first, isolates figures and tables as
standalone atomic chunks with outer tags stripped, stitches
interrupted narrative prose across figure boundaries, and recursively
splits oversized narrative prose by paragraph and sentence up to max_chars.
Preserves document order via anchor interleaving.
Parameters:
Name
Type
Description
Default
markdown
str
Raw Markdown string to chunk.
required
max_chars
int
Maximum character count per chunk. Defaults to
CHUNK_MAX_CHARS from config.
defchunk_markdown(markdown:str,max_chars:int=CHUNK_MAX_CHARS,overlap_chars:int=CHUNK_OVERLAP_CHARS,document_title:str="",)->list[ChunkResult]:""" Chunk a Markdown document into semantically coherent pieces. Splits by ATX headers (H1–H3) first, isolates figures and tables as standalone atomic chunks with outer <figure> tags stripped, stitches interrupted narrative prose across figure boundaries, and recursively splits oversized narrative prose by paragraph and sentence up to ``max_chars``. Preserves document order via anchor interleaving. Args: markdown: Raw Markdown string to chunk. max_chars: Maximum character count per chunk. Defaults to ``CHUNK_MAX_CHARS`` from config. overlap_chars: Characters repeated at chunk boundaries for context continuity. Defaults to ``CHUNK_OVERLAP_CHARS`` from config. document_title: Zotero item title attached to every ``ChunkResult``. Returns: Ordered list of ``ChunkResult`` objects, one per non-empty sub-chunk. """# 0. Build line index on verbatim raw markdown before any masking or splittingline_index=LineIndex(markdown)fm_block,remaining_body=_extract_leading_frontmatter(markdown)chunks:list[ChunkResult]=[]idx=0iffm_block:fm_match=_FRONTMATTER_RE.match(markdown)iffm_match:fm_raw=fm_match.group(0)fm_end_line=len(fm_raw.rstrip("\r\n").splitlines())else:fm_end_line=len(fm_block.rstrip("\r\n").splitlines())chunks.append(ChunkResult(text=fm_block,heading_path=[],chunk_index=idx,document_title=document_title,chunk_type="frontmatter",line_start=1,line_end=max(1,fm_end_line),))idx+=1sections=_split_by_headers(remaining_body)doc_search_cursor=0forheading_path,bodyinsections:ifnotbody.strip():continue# 1. Mask fenced code blocks before scanning for figurescode_masked_body,code_masks=_mask_code_blocks(body)# 2. Extract all <figure> blocks and record their anchor positionsextracted_figures:list[_ExtractedFigure]=[]formatchin_FIGURE_RE.finditer(code_masked_body):raw_fig_tag=match.group(0)anchor_pos=match.start()# Unmask any code placeholders inside figure if presentunmasked_fig_tag=_unmask_code_blocks(raw_fig_tag,code_masks)orig_verbatim_fig_tag=unmasked_fig_tag# Determine type from attributesifre.search(r'data-type=["\']table["\']',unmasked_fig_tag,re.IGNORECASE):fig_type="table"else:fig_type="figure"# Extract image path if presentimage_path=Noneiffig_type=="figure":md_img=_MD_IMG_RE.search(unmasked_fig_tag)html_img=_HTML_IMG_RE.search(unmasked_fig_tag)ifmd_img:image_path=_normalize_image_path(md_img.group(2))elifhtml_img:image_path=_normalize_image_path(html_img.group(1))# Extract synthetic caption and determine caption source & modelcaption_source=Nonevlm_model=Nonesynth_match=_SYNTH_CAPTION_RE.search(unmasked_fig_tag)ifsynth_match:attrs=synth_match.group(1)synth_text=synth_match.group(2).strip()src_attr=re.search(r'data-source=[\"\']([^\"\']+)[\"\']',attrs,re.IGNORECASE)ifsrc_attr:caption_source=src_attr.group(1)else:ifany(cueinsynth_textforcuein("**Type**:","Type:","**Key Elements**:","**Takeaway**:","Takeaway:")):caption_source="vlm"else:caption_source="datalab"model_attr=re.search(r'data-model=[\"\']([^\"\']+)[\"\']',attrs,re.IGNORECASE)ifmodel_attr:vlm_model=model_attr.group(1)# Format synthetic caption into clean Markdown [Visual Summary]unmasked_fig_tag=_SYNTH_CAPTION_RE.sub(lambdam:f"\n\n[Visual Summary]:\n{m.group(2).strip()}",unmasked_fig_tag)# Determine figure start/end line in the source markdown using the verbatim tagfig_start_char=markdown.find(orig_verbatim_fig_tag,doc_search_cursor)iffig_start_char==-1:fig_start_char=markdown.find(orig_verbatim_fig_tag)iffig_start_char!=-1:fig_end_char=fig_start_char+len(orig_verbatim_fig_tag)fig_l_start,fig_l_end=line_index.span_to_lines(fig_start_char,fig_end_char)else:fig_l_start=1fig_l_end=1fig_l_end=max(fig_l_start,fig_l_end)# Strip all HTML wrapper tags from the figure chunk textinner_content=re.sub(r"^<figure\b[^>]*>","",unmasked_fig_tag,flags=re.IGNORECASE).strip()inner_content=re.sub(r"</figure>$","",inner_content,flags=re.IGNORECASE).strip()inner_content=re.sub(r"</?figcaption\b[^>]*>","",inner_content,flags=re.IGNORECASE).strip()inner_content=re.sub(r"</?synthetic_caption\b[^>]*>","",inner_content,flags=re.IGNORECASE).strip()ifinner_content:clean_inner=re.sub(r"\n{3,}","\n\n",inner_content).strip()extracted_figures.append(_ExtractedFigure(anchor=anchor_pos,text=clean_inner,chunk_type=fig_type,image_path=image_pathiffig_type=="figure"elseNone,caption_source=caption_sourceiffig_type=="figure"elseNone,vlm_model=vlm_modeliffig_type=="figure"elseNone,line_start=fig_l_start,line_end=fig_l_end,))# 3. Stitch narrative prose by replacing figures with empty string and collapsing redundant blank linesstitched_masked_body=_FIGURE_RE.sub("",code_masked_body)stitched_masked_body=re.sub(r"\n{3,}","\n\n",stitched_masked_body)stitched_body=_unmask_code_blocks(stitched_masked_body,code_masks).strip()# 4. Split stitched narrative prose into size-bounded sub-chunksprose_sub_chunks:list[str]=[]ifstitched_body:prose_sub_chunks=_recursive_split(stitched_body,max_chars,overlap_chars)# 5. Interleave prose chunks and figure chunks to preserve document flowpending_figs=list(extracted_figures)search_cursor=0is_first_prose=Trueforsubinprose_sub_chunks:sub_str=re.sub(r"\n{3,}","\n\n",sub.strip()).strip()ifnotsub_str:continue# Locate position of this prose chunk in code_masked_bodynon_empty_lines=[l.strip()forlinsub_str.splitlines()ifl.strip()]first_line=non_empty_lines[0][:40]ifnon_empty_lineselse""last_line=non_empty_lines[-1][-40:]ifnon_empty_lineselse""prose_start=(code_masked_body.find(first_line,search_cursor)iffirst_lineelsesearch_cursor)ifprose_start==-1:prose_start=search_cursorelse:search_cursor=prose_startprose_end=(code_masked_body.find(last_line,search_cursor)iflast_lineelsesearch_cursor)ifprose_end==-1:prose_end=prose_start+len(sub_str)else:prose_end+=len(last_line)search_cursor=prose_end# Map prose start/end lines in verbatim markdownp_start=(markdown.find(first_line,doc_search_cursor)iffirst_lineelsedoc_search_cursor)ifp_start==-1andfirst_line:p_start=markdown.find(first_line)ifp_start==-1:p_start=doc_search_cursorp_end=(markdown.find(last_line,p_start)iflast_lineelsep_start)ifp_end==-1andlast_line:p_end=markdown.find(last_line)ifp_end==-1:p_end=p_start+len(sub_str)else:p_end=p_end+len(last_line)p_line_start,p_line_end=line_index.span_to_lines(p_start,p_end)# If this is the first prose chunk under an ATX heading, encompass the heading lineifis_first_proseandheading_path:h_matches=list(re.finditer(r"^(#{1,6})\s+"+re.escape(heading_path[-1]),markdown[:p_start],re.MULTILINE,))ifh_matches:h_line=line_index.char_to_line(h_matches[-1].start())p_line_start=min(p_line_start,h_line)is_first_prose=Falsep_line_end=max(p_line_start,p_line_end)doc_search_cursor=max(doc_search_cursor,p_start+1)# Emit figures that appeared before or at the start of this prose chunkwhilepending_figsandpending_figs[0].anchor<=prose_start:fig=pending_figs.pop(0)chunks.append(ChunkResult(text=fig.text,heading_path=heading_path,chunk_index=idx,document_title=document_title,chunk_type=fig.chunk_type,image_path=fig.image_path,caption_source=fig.caption_source,vlm_model=fig.vlm_model,line_start=fig.line_start,line_end=fig.line_end,))idx+=1# Emit the prose chunkprose_type=determine_chunk_type(sub_str)prose_img_path=Noneprose_cap_source=Noneifprose_type=="figure":md_img=_MD_IMG_RE.search(sub_str)html_img=_HTML_IMG_RE.search(sub_str)ifmd_img:prose_img_path=_normalize_image_path(md_img.group(2))elifhtml_img:prose_img_path=_normalize_image_path(html_img.group(1))chunks.append(ChunkResult(text=sub_str,heading_path=heading_path,chunk_index=idx,document_title=document_title,chunk_type=prose_type,image_path=prose_img_path,caption_source=prose_cap_source,line_start=p_line_start,line_end=p_line_end,))idx+=1# Emit figures that appeared within this prose chunk spanwhilepending_figsandpending_figs[0].anchor<=prose_end:fig=pending_figs.pop(0)chunks.append(ChunkResult(text=fig.text,heading_path=heading_path,chunk_index=idx,document_title=document_title,chunk_type=fig.chunk_type,image_path=fig.image_path,caption_source=fig.caption_source,vlm_model=fig.vlm_model,line_start=fig.line_start,line_end=fig.line_end,))idx+=1# Emit any trailing figures in the sectionwhilepending_figs:fig=pending_figs.pop(0)chunks.append(ChunkResult(text=fig.text,heading_path=heading_path,chunk_index=idx,document_title=document_title,chunk_type=fig.chunk_type,image_path=fig.image_path,caption_source=fig.caption_source,vlm_model=fig.vlm_model,line_start=fig.line_start,line_end=fig.line_end,))idx+=1returnchunks