Cross references
Some checks failed
build_docker / essential (push) Successful in 1s
build_docker / build_paddle_ocr (push) Successful in 5m6s
build_docker / build_raytune (push) Has been cancelled
build_docker / build_easyocr_gpu (push) Has been cancelled
build_docker / build_doctr (push) Has been cancelled
build_docker / build_doctr_gpu (push) Has been cancelled
build_docker / build_paddle_ocr_gpu (push) Has started running
build_docker / build_easyocr (push) Has been cancelled
Some checks failed
build_docker / essential (push) Successful in 1s
build_docker / build_paddle_ocr (push) Successful in 5m6s
build_docker / build_raytune (push) Has been cancelled
build_docker / build_easyocr_gpu (push) Has been cancelled
build_docker / build_doctr (push) Has been cancelled
build_docker / build_doctr_gpu (push) Has been cancelled
build_docker / build_paddle_ocr_gpu (push) Has started running
build_docker / build_easyocr (push) Has been cancelled
This commit is contained in:
@@ -122,7 +122,7 @@ flowchart TB
|
|||||||
i2["plantilla_individual.htm"]
|
i2["plantilla_individual.htm"]
|
||||||
end
|
end
|
||||||
|
|
||||||
scripts["apply_content.py<br/>generate_mermaid_figures.py"]
|
scripts["apply_content.py<br/>content_handlers.py<br/>markdown_utils.py<br/>generate_mermaid_figures.py"]
|
||||||
config["claude.md<br/>README.md"]
|
config["claude.md<br/>README.md"]
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|||||||
583
apply_content.py
583
apply_content.py
@@ -1,136 +1,62 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Replace template content with thesis content from docs/ folder using BeautifulSoup."""
|
"""Replace template content with thesis content from docs/ folder using BeautifulSoup.
|
||||||
|
|
||||||
|
This module orchestrates the conversion of markdown documentation to UNIR's
|
||||||
|
Word template format. Content handling is delegated to:
|
||||||
|
- markdown_utils.py: Utility functions for markdown parsing
|
||||||
|
- content_handlers.py: Block-level content handlers (tables, figures, lists, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from bs4 import BeautifulSoup, NavigableString
|
from bs4 import BeautifulSoup, NavigableString
|
||||||
from latex2mathml.converter import convert as latex_to_mathml
|
|
||||||
from PIL import Image
|
from markdown_utils import (
|
||||||
|
read_file,
|
||||||
|
write_file,
|
||||||
|
md_to_html_para,
|
||||||
|
convert_latex_formulas,
|
||||||
|
is_source_line,
|
||||||
|
is_leyenda_line,
|
||||||
|
split_into_paragraphs,
|
||||||
|
SOURCE_LINE_RE,
|
||||||
|
)
|
||||||
|
from content_handlers import (
|
||||||
|
handle_mermaid_diagram,
|
||||||
|
handle_code_block,
|
||||||
|
handle_header,
|
||||||
|
handle_table,
|
||||||
|
handle_blockquote,
|
||||||
|
handle_bullet_list,
|
||||||
|
handle_numbered_list,
|
||||||
|
)
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
TEMPLATE_INPUT = os.path.join(BASE_DIR, 'instructions/plantilla_individual.htm')
|
TEMPLATE_INPUT = os.path.join(BASE_DIR, 'instructions/plantilla_individual.htm')
|
||||||
TEMPLATE_OUTPUT = os.path.join(BASE_DIR, 'thesis_output/plantilla_individual.htm')
|
TEMPLATE_OUTPUT = os.path.join(BASE_DIR, 'thesis_output/plantilla_individual.htm')
|
||||||
DOCS_DIR = os.path.join(BASE_DIR, 'docs')
|
DOCS_DIR = os.path.join(BASE_DIR, 'docs')
|
||||||
|
|
||||||
# Accept Fuente/Source lines with or without markdown bold
|
|
||||||
SOURCE_LINE_RE = re.compile(r'^\s*(?:\*{1,2})?(Fuente|Source):(?:\*{1,2})?\s*(.*)$', re.IGNORECASE)
|
|
||||||
# Accept Leyenda lines with or without markdown bold
|
|
||||||
LEYENDA_LINE_RE = re.compile(r'^\s*(?:\*{1,2})?Leyenda:(?:\*{1,2})?\s*(.*)$', re.IGNORECASE)
|
|
||||||
|
|
||||||
# Global counters for tables and figures
|
def parse_md_to_html_blocks(md_content, is_anexo=False, counters=None):
|
||||||
table_counter = 0
|
"""Convert markdown content to HTML blocks with template styles.
|
||||||
figure_counter = 0
|
|
||||||
anexo_table_counter = 0
|
|
||||||
anexo_figure_counter = 0
|
|
||||||
# Global sequential counter for figure filenames (figura_1.png, figura_2.png, etc.)
|
|
||||||
global_figure_index = 0
|
|
||||||
|
|
||||||
def read_file(path):
|
Args:
|
||||||
try:
|
md_content: Markdown content string
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
is_anexo: Boolean indicating if processing Anexo section
|
||||||
return f.read()
|
counters: Dict with table/figure counters. If None, creates new one.
|
||||||
except UnicodeDecodeError:
|
|
||||||
with open(path, 'r', encoding='latin-1') as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
def write_file(path, content):
|
Returns:
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
Tuple of (html_string, counters) where counters is the updated dict
|
||||||
f.write(content)
|
"""
|
||||||
|
if counters is None:
|
||||||
def md_to_html_para(text):
|
counters = {
|
||||||
"""Convert markdown inline formatting to HTML."""
|
'table': 0,
|
||||||
# Bold
|
'figure': 0,
|
||||||
text = re.sub(r'\*\*([^*]+)\*\*', r'<b>\1</b>', text)
|
'anexo_table': 0,
|
||||||
# Italic
|
'anexo_figure': 0,
|
||||||
text = re.sub(r'\*([^*]+)\*', r'<i>\1</i>', text)
|
'global_figure': 0,
|
||||||
# Inline code
|
}
|
||||||
text = re.sub(r'`([^`]+)`', r'<span style="font-family:Consolas;font-size:10pt">\1</span>', text)
|
|
||||||
# Links [text](url) -> <a href="url">text</a>
|
|
||||||
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
def convert_latex_formulas(text):
|
|
||||||
"""Convert LaTeX formulas to MathML for Word compatibility."""
|
|
||||||
# Block formulas $$...$$
|
|
||||||
def convert_block(match):
|
|
||||||
latex = match.group(1)
|
|
||||||
try:
|
|
||||||
mathml = latex_to_mathml(latex, display="block")
|
|
||||||
return f'<p class=MsoNormal style="text-align:center">{mathml}</p>'
|
|
||||||
except:
|
|
||||||
return match.group(0) # Keep original if conversion fails
|
|
||||||
|
|
||||||
text = re.sub(r'\$\$([^$]+)\$\$', convert_block, text)
|
|
||||||
|
|
||||||
# Inline formulas $...$
|
|
||||||
def convert_inline(match):
|
|
||||||
latex = match.group(1)
|
|
||||||
try:
|
|
||||||
return latex_to_mathml(latex, display="inline")
|
|
||||||
except:
|
|
||||||
return match.group(0)
|
|
||||||
|
|
||||||
text = re.sub(r'\$([^$]+)\$', convert_inline, text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
def extract_source_from_line(line):
|
|
||||||
"""Return source text if line is a Fuente/Source line, otherwise None."""
|
|
||||||
match = SOURCE_LINE_RE.match(line.strip())
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return match.group(2).strip()
|
|
||||||
|
|
||||||
def is_source_line(line):
|
|
||||||
"""Check whether a line starts with Fuente:/Source: (optionally bold)."""
|
|
||||||
return SOURCE_LINE_RE.match(line.strip()) is not None
|
|
||||||
|
|
||||||
def extract_leyenda_from_line(line):
|
|
||||||
"""Return leyenda text if line is a Leyenda line, otherwise None."""
|
|
||||||
match = LEYENDA_LINE_RE.match(line.strip())
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
return match.group(1).strip()
|
|
||||||
|
|
||||||
def is_leyenda_line(line):
|
|
||||||
"""Check whether a line starts with Leyenda: (optionally bold)."""
|
|
||||||
return LEYENDA_LINE_RE.match(line.strip()) is not None
|
|
||||||
|
|
||||||
def extract_table_title(lines, current_index):
|
|
||||||
"""Look for table title in preceding lines (e.g., **Tabla 1.** *Title*)."""
|
|
||||||
# Check previous non-empty lines for table title
|
|
||||||
for i in range(current_index - 1, max(0, current_index - 5), -1):
|
|
||||||
line = lines[i].strip()
|
|
||||||
if line.startswith('**Tabla') or line.startswith('*Tabla'):
|
|
||||||
return line
|
|
||||||
if line and not line.startswith('|'):
|
|
||||||
break
|
|
||||||
return None
|
|
||||||
|
|
||||||
def extract_figure_title_from_mermaid(lines, current_index):
|
|
||||||
"""Extract title from mermaid diagram or preceding text."""
|
|
||||||
# Look for title in mermaid content
|
|
||||||
for i in range(current_index + 1, min(len(lines), current_index + 20)):
|
|
||||||
line = lines[i].strip()
|
|
||||||
if line.startswith('```'):
|
|
||||||
break
|
|
||||||
if 'title' in line.lower():
|
|
||||||
# Extract title from: title "Some Title"
|
|
||||||
match = re.search(r'title\s+["\']([^"\']+)["\']', line)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
|
|
||||||
# Check preceding lines for figure reference
|
|
||||||
for i in range(current_index - 1, max(0, current_index - 3), -1):
|
|
||||||
line = lines[i].strip()
|
|
||||||
if line.startswith('**Figura') or 'Figura' in line:
|
|
||||||
return line
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_md_to_html_blocks(md_content, is_anexo=False):
|
|
||||||
"""Convert markdown content to HTML blocks with template styles."""
|
|
||||||
global table_counter, figure_counter, anexo_table_counter, anexo_figure_counter, global_figure_index
|
|
||||||
|
|
||||||
html_blocks = []
|
html_blocks = []
|
||||||
lines = md_content.split('\n')
|
lines = md_content.split('\n')
|
||||||
@@ -146,346 +72,49 @@ def parse_md_to_html_blocks(md_content, is_anexo=False):
|
|||||||
|
|
||||||
# Mermaid diagram - convert to figure with actual image
|
# Mermaid diagram - convert to figure with actual image
|
||||||
if line.strip().startswith('```mermaid'):
|
if line.strip().startswith('```mermaid'):
|
||||||
# Always increment global index for sequential filenames
|
blocks, i = handle_mermaid_diagram(lines, i, counters, is_anexo)
|
||||||
global_figure_index += 1
|
html_blocks.extend(blocks)
|
||||||
|
|
||||||
# Use Anexo-specific counter with "A" prefix for display, or global counter
|
|
||||||
if is_anexo:
|
|
||||||
anexo_figure_counter += 1
|
|
||||||
fig_num = f"A{anexo_figure_counter}" # Display number: A1, A2, A3...
|
|
||||||
else:
|
|
||||||
figure_counter += 1
|
|
||||||
fig_num = str(figure_counter) # Display number: 1, 2, 3...
|
|
||||||
|
|
||||||
mermaid_lines = []
|
|
||||||
i += 1
|
|
||||||
while i < len(lines) and not lines[i].strip() == '```':
|
|
||||||
mermaid_lines.append(lines[i])
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Try to extract title from mermaid content (YAML format)
|
|
||||||
mermaid_content = '\n'.join(mermaid_lines)
|
|
||||||
# Match title with quotes: title: "Something" or title: 'Something'
|
|
||||||
title_match = re.search(r'title:\s*["\']([^"\']+)["\']', mermaid_content)
|
|
||||||
if not title_match:
|
|
||||||
# Match title without quotes: title: Something
|
|
||||||
title_match = re.search(r'title:\s*([^"\'\n]+)', mermaid_content)
|
|
||||||
if title_match:
|
|
||||||
fig_title = title_match.group(1).strip()
|
|
||||||
else:
|
|
||||||
fig_title = f"Diagrama {fig_num}"
|
|
||||||
|
|
||||||
# Use global sequential index for filename (figura_1.png, figura_2.png, etc.)
|
|
||||||
fig_file = f'figures/figura_{global_figure_index}.png'
|
|
||||||
fig_path = os.path.join(BASE_DIR, 'thesis_output', fig_file)
|
|
||||||
|
|
||||||
# Create figure with MsoCaption class and proper Word SEQ field for cross-reference
|
|
||||||
# Format: "Figura X." in bold, title in italic (per UNIR guidelines)
|
|
||||||
# Word TOC looks for text with Caption style - anchor must be outside main caption text
|
|
||||||
bookmark_id = f"_Ref_Fig{fig_num}"
|
|
||||||
# mso-pagination:keep-with-next ensures caption stays with figure image (correct MSO property)
|
|
||||||
# For Anexo figures, use static text (no SEQ field) to prevent Word from overwriting A1, A2...
|
|
||||||
# Add TC field so Anexo figures appear in Table of Figures index
|
|
||||||
# Use \f c to match the TOC field identifier in the template
|
|
||||||
if is_anexo:
|
|
||||||
tc_field = f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> TC "Figura {fig_num}. {fig_title}" \\f c \\l 1 <span style='mso-element:field-end'></span><![endif]-->'''
|
|
||||||
html_blocks.append(f'''<a name="{bookmark_id}"></a>{tc_field}<p class=MsoCaption style="text-align:center;mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Figura {fig_num}.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{fig_title}</span></i></p>''')
|
|
||||||
else:
|
|
||||||
html_blocks.append(f'''<a name="{bookmark_id}"></a><p class=MsoCaption style="text-align:center;mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Figura <!--[if supportFields]><span style='mso-element:field-begin'></span> SEQ Figura \\* ARABIC <span style='mso-element:field-separator'></span><![endif]-->{fig_num}<!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{fig_title}</span></i></p>''')
|
|
||||||
|
|
||||||
if os.path.exists(fig_path):
|
|
||||||
# Read actual image dimensions and scale to fit page width
|
|
||||||
img = Image.open(fig_path)
|
|
||||||
orig_w, orig_h = img.size
|
|
||||||
|
|
||||||
# Scale to fit max width of 566px (15cm at 96dpi) while preserving aspect ratio
|
|
||||||
max_width = 566
|
|
||||||
if orig_w > max_width:
|
|
||||||
scale = max_width / orig_w
|
|
||||||
new_w = max_width
|
|
||||||
new_h = int(orig_h * scale)
|
|
||||||
else:
|
|
||||||
new_w, new_h = orig_w, orig_h
|
|
||||||
|
|
||||||
# Convert to pt (1px at 96dpi = 0.75pt)
|
|
||||||
w_pt = new_w * 0.75
|
|
||||||
h_pt = new_h * 0.75
|
|
||||||
|
|
||||||
# mso-pagination:keep-with-next ensures image stays with source line
|
|
||||||
html_blocks.append(f'''<p class=MsoNormal style="text-align:center;mso-pagination:keep-with-next"><span lang=ES><img width="{new_w}" height="{new_h}" style="width:{w_pt}pt;height:{h_pt}pt;display:block;margin:0 auto" src="{fig_file}" alt="{fig_title}"/></span></p>''')
|
|
||||||
else:
|
|
||||||
# Fallback to placeholder
|
|
||||||
# mso-pagination:keep-with-next ensures placeholder stays with source line
|
|
||||||
html_blocks.append(f'''<p class=MsoNormal style="text-align:center;mso-pagination:keep-with-next;border:1px dashed #999;padding:20px;margin:10px 40px;background:#f9f9f9"><span lang=ES style="color:#666">[Insertar diagrama Mermaid aquí]</span></p>''')
|
|
||||||
|
|
||||||
# Check if next non-empty line has custom Fuente
|
|
||||||
custom_source = None
|
|
||||||
fig_leyenda = None
|
|
||||||
lookahead = i + 1
|
|
||||||
while lookahead < len(lines) and not lines[lookahead].strip():
|
|
||||||
lookahead += 1
|
|
||||||
if lookahead < len(lines):
|
|
||||||
next_line = lines[lookahead].strip()
|
|
||||||
if is_source_line(next_line):
|
|
||||||
# Extract custom source, removing markdown formatting
|
|
||||||
custom_source = extract_source_from_line(next_line)
|
|
||||||
# Ensure it ends with a period
|
|
||||||
if custom_source and not custom_source.endswith('.'):
|
|
||||||
custom_source += '.'
|
|
||||||
# Skip this line by advancing i past it
|
|
||||||
i = lookahead
|
|
||||||
# Check for Leyenda after source
|
|
||||||
leyenda_idx = i + 1
|
|
||||||
while leyenda_idx < len(lines) and not lines[leyenda_idx].strip():
|
|
||||||
leyenda_idx += 1
|
|
||||||
if leyenda_idx < len(lines) and is_leyenda_line(lines[leyenda_idx]):
|
|
||||||
fig_leyenda = extract_leyenda_from_line(lines[leyenda_idx])
|
|
||||||
i = leyenda_idx
|
|
||||||
|
|
||||||
if custom_source:
|
|
||||||
source_html = md_to_html_para(custom_source)
|
|
||||||
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Fuente: {source_html}</span></p>''')
|
|
||||||
else:
|
|
||||||
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Fuente: Elaboración propia.</span></p>''')
|
|
||||||
|
|
||||||
# Add leyenda if present (same style as Fuente, new line)
|
|
||||||
if fig_leyenda:
|
|
||||||
leyenda_html = md_to_html_para(fig_leyenda)
|
|
||||||
if not fig_leyenda.endswith('.'):
|
|
||||||
leyenda_html += '.'
|
|
||||||
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Leyenda: {leyenda_html}</span></p>''')
|
|
||||||
|
|
||||||
html_blocks.append('<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>')
|
|
||||||
i += 1
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Code block (non-mermaid)
|
# Code block (non-mermaid)
|
||||||
if line.strip().startswith('```'):
|
if line.strip().startswith('```'):
|
||||||
code_lang = line.strip()[3:]
|
blocks, i = handle_code_block(lines, i)
|
||||||
code_lines = []
|
html_blocks.extend(blocks)
|
||||||
i += 1
|
continue
|
||||||
while i < len(lines) and not lines[i].strip().startswith('```'):
|
|
||||||
code_lines.append(lines[i])
|
# Headers
|
||||||
i += 1
|
if line.startswith('#'):
|
||||||
code = '\n'.join(code_lines)
|
header_html = handle_header(line, is_anexo)
|
||||||
# Escape HTML entities in code
|
if header_html is not None:
|
||||||
code = code.replace('&', '&').replace('<', '<').replace('>', '>')
|
html_blocks.append(header_html)
|
||||||
html_blocks.append(f'''<div style="background:#E6F4F9;border-top:solid #0098CD .5pt;border-bottom:solid #0098CD .5pt;padding:8pt 12pt;margin:6pt 0">
|
|
||||||
<pre style="font-family:Consolas,monospace;font-size:9pt;color:#333333;margin:0;white-space:pre-wrap;word-wrap:break-word">{code}</pre>
|
|
||||||
</div>''')
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Headers - ## becomes h2, ### becomes h3
|
# Table
|
||||||
if line.startswith('####'):
|
|
||||||
text = line.lstrip('#').strip()
|
|
||||||
# Apply consistent styling like h2/h3, disable numbering for h4
|
|
||||||
html_blocks.append(f'<h4 style="mso-list:none"><b><span lang=ES style="text-transform:none">{text}</span></b></h4>')
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
elif line.startswith('###'):
|
|
||||||
text = line.lstrip('#').strip()
|
|
||||||
# Disable auto-numbering for Anexo content or A.x headings
|
|
||||||
if is_anexo or re.match(r'^A\.\d+', text):
|
|
||||||
# mso-list:none explicitly disables inherited list numbering from template CSS
|
|
||||||
html_blocks.append(f'<h3 style="mso-list:none"><span lang=ES style="text-transform:none">{text}</span></h3>')
|
|
||||||
else:
|
|
||||||
html_blocks.append(f'<h3 style="mso-list:l22 level3 lfo18"><span lang=ES style="text-transform:none">{text}</span></h3>')
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
elif line.startswith('##'):
|
|
||||||
text = line.lstrip('#').strip()
|
|
||||||
# Disable auto-numbering for Anexo content or A.x headings
|
|
||||||
if is_anexo or re.match(r'^A\.\d+', text):
|
|
||||||
# mso-list:none explicitly disables inherited list numbering from template CSS
|
|
||||||
html_blocks.append(f'<h2 style="mso-list:none"><span lang=ES style="text-transform:none">{text}</span></h2>')
|
|
||||||
else:
|
|
||||||
html_blocks.append(f'<h2 style="mso-list:l22 level2 lfo18"><span lang=ES style="text-transform:none">{text}</span></h2>')
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
elif line.startswith('#'):
|
|
||||||
# Skip h1 - we keep the original
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Table - check for table title pattern first
|
|
||||||
if '|' in line and i + 1 < len(lines) and '---' in lines[i + 1]:
|
if '|' in line and i + 1 < len(lines) and '---' in lines[i + 1]:
|
||||||
# Use Anexo-specific counter with "A" prefix, or global counter
|
blocks, i = handle_table(lines, i, counters, is_anexo)
|
||||||
if is_anexo:
|
html_blocks.extend(blocks)
|
||||||
anexo_table_counter += 1
|
|
||||||
table_num = f"A{anexo_table_counter}"
|
|
||||||
else:
|
|
||||||
table_counter += 1
|
|
||||||
table_num = str(table_counter)
|
|
||||||
|
|
||||||
# Check if previous line has table title (e.g., **Tabla 1.** *Title*)
|
|
||||||
table_title = None
|
|
||||||
alt_title = None # Alternative title from **bold text:** pattern
|
|
||||||
table_source = "Elaboración propia"
|
|
||||||
|
|
||||||
# Look back for table title
|
|
||||||
for j in range(i - 1, max(0, i - 5), -1):
|
|
||||||
prev_line = lines[j].strip()
|
|
||||||
if prev_line.startswith('**Tabla') or prev_line.startswith('*Tabla'):
|
|
||||||
# Extract title text
|
|
||||||
table_title = re.sub(r'\*+', '', prev_line).strip()
|
|
||||||
break
|
|
||||||
elif prev_line.startswith('**') and prev_line.endswith(':**'):
|
|
||||||
# Alternative: **Bold title:** pattern (for informal tables)
|
|
||||||
alt_title = re.sub(r'\*+', '', prev_line).rstrip(':').strip()
|
|
||||||
elif prev_line and not prev_line.startswith('|'):
|
|
||||||
break
|
|
||||||
|
|
||||||
# Parse table
|
|
||||||
table_lines = []
|
|
||||||
while i < len(lines) and '|' in lines[i]:
|
|
||||||
if '---' not in lines[i]:
|
|
||||||
table_lines.append(lines[i])
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# Look ahead for source (skip blank lines first)
|
|
||||||
source_idx = i
|
|
||||||
table_leyenda = None
|
|
||||||
while source_idx < len(lines) and not lines[source_idx].strip():
|
|
||||||
source_idx += 1
|
|
||||||
if source_idx < len(lines) and is_source_line(lines[source_idx]):
|
|
||||||
table_source = extract_source_from_line(lines[source_idx])
|
|
||||||
i = source_idx + 1
|
|
||||||
# Check for Leyenda after source (skip blank lines)
|
|
||||||
leyenda_idx = i
|
|
||||||
while leyenda_idx < len(lines) and not lines[leyenda_idx].strip():
|
|
||||||
leyenda_idx += 1
|
|
||||||
if leyenda_idx < len(lines) and is_leyenda_line(lines[leyenda_idx]):
|
|
||||||
table_leyenda = extract_leyenda_from_line(lines[leyenda_idx])
|
|
||||||
i = leyenda_idx + 1
|
|
||||||
|
|
||||||
# Add table title with MsoCaption class and proper Word SEQ field for cross-reference
|
|
||||||
# Format: "Tabla X." in bold, title in italic (per UNIR guidelines)
|
|
||||||
# Word TOC looks for text with Caption style - anchor must be outside main caption text
|
|
||||||
bookmark_id = f"_Ref_Tab{table_num}"
|
|
||||||
if table_title:
|
|
||||||
# Remove any "Tabla X." or "Tabla AX." pattern from the title
|
|
||||||
clean_title = re.sub(r'^Tabla\s+[A-Z]?\d+\.\s*', '', table_title).strip()
|
|
||||||
elif alt_title:
|
|
||||||
# Use alternative title from **bold text:** pattern
|
|
||||||
clean_title = alt_title
|
|
||||||
else:
|
|
||||||
clean_title = "Tabla de datos."
|
|
||||||
# mso-pagination:keep-with-next ensures caption stays with table (correct MSO property)
|
|
||||||
# For Anexo tables, use static text (no SEQ field) to prevent Word from overwriting A1, A2...
|
|
||||||
# Add TC field so Anexo tables appear in Table of Tables index
|
|
||||||
# Use \f t identifier - template TOC field will be modified to include this
|
|
||||||
if is_anexo:
|
|
||||||
tc_field = f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> TC "Tabla {table_num}. {clean_title}" \\f t \\l 1 <span style='mso-element:field-end'></span><![endif]-->'''
|
|
||||||
html_blocks.append(f'''<a name="{bookmark_id}"></a>{tc_field}<p class=MsoCaption style="mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Tabla {table_num}.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{clean_title}</span></i></p>''')
|
|
||||||
else:
|
|
||||||
html_blocks.append(f'''<a name="{bookmark_id}"></a><p class=MsoCaption style="mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Tabla <!--[if supportFields]><span style='mso-element:field-begin'></span> SEQ Tabla \\* ARABIC <span style='mso-element:field-separator'></span><![endif]-->{table_num}<!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{clean_title}</span></i></p>''')
|
|
||||||
|
|
||||||
# Build table HTML with APA style (horizontal lines only, no vertical)
|
|
||||||
table_html = '<div align="center"><table class=MsoTableGrid border=1 cellspacing=0 cellpadding=0 align="center" style="border-collapse:collapse;margin-left:auto;margin-right:auto;mso-table-style-name:\'Plain Table 1\'">'
|
|
||||||
for j, tline in enumerate(table_lines):
|
|
||||||
cells = [c.strip() for c in tline.split('|')[1:-1]]
|
|
||||||
table_html += '<tr>'
|
|
||||||
for cell in cells:
|
|
||||||
if j == 0:
|
|
||||||
# Header row: top and bottom border, bold text
|
|
||||||
table_html += f'<td style="border-top:solid windowtext 1.0pt;border-bottom:solid windowtext 1.0pt;border-left:none;border-right:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><b><span lang=ES>{md_to_html_para(cell)}</span></b></p></td>'
|
|
||||||
elif j == len(table_lines) - 1:
|
|
||||||
# Last row: bottom border only
|
|
||||||
table_html += f'<td style="border-top:none;border-bottom:solid windowtext 1.0pt;border-left:none;border-right:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><span lang=ES>{md_to_html_para(cell)}</span></p></td>'
|
|
||||||
else:
|
|
||||||
# Middle rows: no borders
|
|
||||||
table_html += f'<td style="border:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><span lang=ES>{md_to_html_para(cell)}</span></p></td>'
|
|
||||||
table_html += '</tr>'
|
|
||||||
table_html += '</table></div>'
|
|
||||||
html_blocks.append(table_html)
|
|
||||||
|
|
||||||
# Add source with proper template format (convert markdown links to HTML)
|
|
||||||
source_html = md_to_html_para(table_source)
|
|
||||||
if not table_source.endswith('.'):
|
|
||||||
source_html += '.'
|
|
||||||
html_blocks.append(f'<p class=Piedefoto-tabla style="margin-left:0cm"><span lang=ES>Fuente: {source_html}</span></p>')
|
|
||||||
|
|
||||||
# Add leyenda if present (same style as Fuente, new line)
|
|
||||||
if table_leyenda:
|
|
||||||
leyenda_html = md_to_html_para(table_leyenda)
|
|
||||||
if not table_leyenda.endswith('.'):
|
|
||||||
leyenda_html += '.'
|
|
||||||
html_blocks.append(f'<p class=Piedefoto-tabla style="margin-left:0cm"><span lang=ES>Leyenda: {leyenda_html}</span></p>')
|
|
||||||
|
|
||||||
html_blocks.append('<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>')
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Blockquote
|
# Blockquote
|
||||||
if line.startswith('>'):
|
if line.startswith('>'):
|
||||||
quote_text = line[1:].strip()
|
blocks, i = handle_blockquote(lines, i)
|
||||||
i += 1
|
html_blocks.extend(blocks)
|
||||||
while i < len(lines) and lines[i].startswith('>'):
|
|
||||||
quote_text += ' ' + lines[i][1:].strip()
|
|
||||||
i += 1
|
|
||||||
html_blocks.append(f'<p class=MsoQuote><i><span lang=ES>{md_to_html_para(quote_text)}</span></i></p>')
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Bullet list (handle blank lines between items)
|
# Bullet list
|
||||||
if re.match(r'^[\-\*\+]\s', line):
|
if re.match(r'^[\-\*\+]\s', line):
|
||||||
# Collect all bullet items first
|
blocks, i = handle_bullet_list(lines, i)
|
||||||
bullet_items = []
|
html_blocks.extend(blocks)
|
||||||
while i < len(lines):
|
|
||||||
# Skip blank lines
|
|
||||||
while i < len(lines) and not lines[i].strip():
|
|
||||||
i += 1
|
|
||||||
# Check if next non-blank line is a bullet item
|
|
||||||
if i < len(lines) and re.match(r'^[\-\*\+]\s', lines[i]):
|
|
||||||
item_text = lines[i][2:].strip()
|
|
||||||
item_text = convert_latex_formulas(item_text)
|
|
||||||
bullet_items.append(md_to_html_para(item_text))
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
# Output with proper First/Middle/Last classes
|
|
||||||
for idx, item in enumerate(bullet_items):
|
|
||||||
if len(bullet_items) == 1:
|
|
||||||
cls = 'MsoListParagraph'
|
|
||||||
elif idx == 0:
|
|
||||||
cls = 'MsoListParagraphCxSpFirst'
|
|
||||||
elif idx == len(bullet_items) - 1:
|
|
||||||
cls = 'MsoListParagraphCxSpLast'
|
|
||||||
else:
|
|
||||||
cls = 'MsoListParagraphCxSpMiddle'
|
|
||||||
html_blocks.append(f'<p class={cls} style="margin-left:36pt;text-indent:-18pt"><span lang=ES style="font-family:Symbol">·</span><span lang=ES style="font-size:7pt"> </span><span lang=ES>{item}</span></p>')
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Numbered list (handle blank lines between items)
|
# Numbered list
|
||||||
if re.match(r'^\d+\.\s', line):
|
if re.match(r'^\d+\.\s', line):
|
||||||
# Collect all numbered items first
|
blocks, i = handle_numbered_list(lines, i)
|
||||||
numbered_items = []
|
html_blocks.extend(blocks)
|
||||||
while i < len(lines):
|
|
||||||
# Skip blank lines
|
|
||||||
while i < len(lines) and not lines[i].strip():
|
|
||||||
i += 1
|
|
||||||
# Check if next non-blank line is a numbered item
|
|
||||||
if i < len(lines) and re.match(r'^\d+\.\s', lines[i]):
|
|
||||||
item_text = re.sub(r'^\d+\.\s*', '', lines[i]).strip()
|
|
||||||
item_text = convert_latex_formulas(item_text)
|
|
||||||
numbered_items.append(md_to_html_para(item_text))
|
|
||||||
i += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
# Output with proper First/Middle/Last classes
|
|
||||||
for idx, item in enumerate(numbered_items):
|
|
||||||
num = idx + 1
|
|
||||||
if len(numbered_items) == 1:
|
|
||||||
cls = 'MsoListParagraph'
|
|
||||||
elif idx == 0:
|
|
||||||
cls = 'MsoListParagraphCxSpFirst'
|
|
||||||
elif idx == len(numbered_items) - 1:
|
|
||||||
cls = 'MsoListParagraphCxSpLast'
|
|
||||||
else:
|
|
||||||
cls = 'MsoListParagraphCxSpMiddle'
|
|
||||||
html_blocks.append(f'<p class={cls} style="margin-left:36pt;text-indent:-18pt"><span lang=ES>{num}.<span style="font-size:7pt"> </span>{item}</span></p>')
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip lines that are just table/figure titles (they'll be handled with the table/figure)
|
# Skip lines that are just table/figure titles
|
||||||
if line.strip().startswith('**Tabla') or line.strip().startswith('*Tabla'):
|
if line.strip().startswith('**Tabla') or line.strip().startswith('*Tabla'):
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
@@ -514,12 +143,23 @@ def parse_md_to_html_blocks(md_content, is_anexo=False):
|
|||||||
else:
|
else:
|
||||||
html_blocks.append(f'<p class=MsoNormal><span lang=ES>{md_to_html_para(para_text)}</span></p>')
|
html_blocks.append(f'<p class=MsoNormal><span lang=ES>{md_to_html_para(para_text)}</span></p>')
|
||||||
|
|
||||||
return '\n\n'.join(html_blocks)
|
return '\n\n'.join(html_blocks), counters
|
||||||
|
|
||||||
def extract_section_content(md_content, is_anexo=False):
|
|
||||||
"""Extract content from markdown, skipping the first # header."""
|
def extract_section_content(md_content, is_anexo=False, counters=None):
|
||||||
|
"""Extract content from markdown, skipping the first # header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
md_content: Markdown content string
|
||||||
|
is_anexo: Boolean indicating if processing Anexo section
|
||||||
|
counters: Dict with table/figure counters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_string, counters)
|
||||||
|
"""
|
||||||
md_content = re.sub(r'^#\s+[^\n]+\n+', '', md_content, count=1)
|
md_content = re.sub(r'^#\s+[^\n]+\n+', '', md_content, count=1)
|
||||||
return parse_md_to_html_blocks(md_content, is_anexo=is_anexo)
|
return parse_md_to_html_blocks(md_content, is_anexo=is_anexo, counters=counters)
|
||||||
|
|
||||||
|
|
||||||
def find_section_element(soup, keyword):
|
def find_section_element(soup, keyword):
|
||||||
"""Find element containing keyword (h1 or special paragraph classes)."""
|
"""Find element containing keyword (h1 or special paragraph classes)."""
|
||||||
@@ -540,6 +180,7 @@ def find_section_element(soup, keyword):
|
|||||||
return p
|
return p
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def remove_elements_between(start_elem, end_elem):
|
def remove_elements_between(start_elem, end_elem):
|
||||||
"""Remove all elements between start and end (exclusive)."""
|
"""Remove all elements between start and end (exclusive)."""
|
||||||
current = start_elem.next_sibling
|
current = start_elem.next_sibling
|
||||||
@@ -553,6 +194,7 @@ def remove_elements_between(start_elem, end_elem):
|
|||||||
elif isinstance(elem, NavigableString):
|
elif isinstance(elem, NavigableString):
|
||||||
elem.extract()
|
elem.extract()
|
||||||
|
|
||||||
|
|
||||||
def format_references(refs_content):
|
def format_references(refs_content):
|
||||||
"""Format references with proper MsoBibliography style."""
|
"""Format references with proper MsoBibliography style."""
|
||||||
refs_content = refs_content.replace('# Referencias bibliográficas {.unnumbered}', '').strip()
|
refs_content = refs_content.replace('# Referencias bibliográficas {.unnumbered}', '').strip()
|
||||||
@@ -566,20 +208,11 @@ def format_references(refs_content):
|
|||||||
# Apply markdown formatting
|
# Apply markdown formatting
|
||||||
formatted = md_to_html_para(line)
|
formatted = md_to_html_para(line)
|
||||||
|
|
||||||
# Use MsoBibliography style with hanging indent (36pt indent, -36pt text-indent)
|
# Use MsoBibliography style with hanging indent
|
||||||
refs_html += f'''<p class=MsoBibliography style="margin-left:36.0pt;text-indent:-36.0pt"><span lang=ES>{formatted}</span></p>\n'''
|
refs_html += f'''<p class=MsoBibliography style="margin-left:36.0pt;text-indent:-36.0pt"><span lang=ES>{formatted}</span></p>\n'''
|
||||||
|
|
||||||
return refs_html
|
return refs_html
|
||||||
|
|
||||||
def split_into_paragraphs(text, lang='ES'):
|
|
||||||
"""Split text by double newlines and wrap each paragraph in <p> tags."""
|
|
||||||
paragraphs = []
|
|
||||||
for para in text.split('\n\n'):
|
|
||||||
para = para.strip()
|
|
||||||
if para:
|
|
||||||
formatted = md_to_html_para(para)
|
|
||||||
paragraphs.append(f'<p class=MsoNormal><span lang={lang}>{formatted}</span></p>')
|
|
||||||
return '\n'.join(paragraphs)
|
|
||||||
|
|
||||||
def extract_resumen_parts(resumen_content):
|
def extract_resumen_parts(resumen_content):
|
||||||
"""Extract Spanish resumen and English abstract from 00_resumen.md"""
|
"""Extract Spanish resumen and English abstract from 00_resumen.md"""
|
||||||
@@ -610,16 +243,21 @@ def extract_resumen_parts(resumen_content):
|
|||||||
|
|
||||||
return spanish_text, spanish_keywords, english_text, english_keywords
|
return spanish_text, spanish_keywords, english_text, english_keywords
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global table_counter, figure_counter, anexo_table_counter, anexo_figure_counter
|
# Initialize counters dict (replaces global counters)
|
||||||
|
counters = {
|
||||||
|
'table': 0,
|
||||||
|
'figure': 0,
|
||||||
|
'anexo_table': 0,
|
||||||
|
'anexo_figure': 0,
|
||||||
|
'global_figure': 0,
|
||||||
|
}
|
||||||
|
|
||||||
print("Reading template...")
|
print("Reading template...")
|
||||||
html_content = read_file(TEMPLATE_INPUT)
|
html_content = read_file(TEMPLATE_INPUT)
|
||||||
|
|
||||||
# Modify the Table of Tables TOC field to include TC entries with \f t identifier
|
# Modify the Table of Tables TOC field to include TC entries with \f t identifier
|
||||||
# Original: TOC \h \z \t "Tablas;1" \c "Tabla"
|
|
||||||
# Modified: TOC \f t \h \z \t "Tablas;1" \c "Tabla"
|
|
||||||
# Use regex to handle whitespace/HTML variations in the TOC field
|
|
||||||
html_content = re.sub(
|
html_content = re.sub(
|
||||||
r'(TOC\s+)(\\h\s+\\z\s+\\t\s*\n?\s*"Tablas;1")',
|
r'(TOC\s+)(\\h\s+\\z\s+\\t\s*\n?\s*"Tablas;1")',
|
||||||
r'\1\\f t \2',
|
r'\1\\f t \2',
|
||||||
@@ -652,7 +290,6 @@ def main():
|
|||||||
print("Replacing Resumen...")
|
print("Replacing Resumen...")
|
||||||
resumen_title = soup.find('p', class_='Ttulondices', string=re.compile(r'Resumen'))
|
resumen_title = soup.find('p', class_='Ttulondices', string=re.compile(r'Resumen'))
|
||||||
if resumen_title:
|
if resumen_title:
|
||||||
# Find and replace content after Resumen title until Abstract
|
|
||||||
current = resumen_title.find_next_sibling()
|
current = resumen_title.find_next_sibling()
|
||||||
elements_to_remove = []
|
elements_to_remove = []
|
||||||
while current:
|
while current:
|
||||||
@@ -666,7 +303,6 @@ def main():
|
|||||||
if hasattr(elem, 'decompose'):
|
if hasattr(elem, 'decompose'):
|
||||||
elem.decompose()
|
elem.decompose()
|
||||||
|
|
||||||
# Insert new resumen content (spanish_text already contains <p> tags)
|
|
||||||
resumen_html = f'''{spanish_text}
|
resumen_html = f'''{spanish_text}
|
||||||
<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>
|
<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>
|
||||||
<p class=MsoNormal><b><span lang=ES>Palabras clave:</span></b><span lang=ES> {spanish_kw}</span></p>
|
<p class=MsoNormal><b><span lang=ES>Palabras clave:</span></b><span lang=ES> {spanish_kw}</span></p>
|
||||||
@@ -681,11 +317,9 @@ def main():
|
|||||||
print("Replacing Abstract...")
|
print("Replacing Abstract...")
|
||||||
abstract_title = soup.find('p', class_='Ttulondices', string=re.compile(r'Abstract'))
|
abstract_title = soup.find('p', class_='Ttulondices', string=re.compile(r'Abstract'))
|
||||||
if abstract_title:
|
if abstract_title:
|
||||||
# Find and replace content after Abstract title until next major section
|
|
||||||
current = abstract_title.find_next_sibling()
|
current = abstract_title.find_next_sibling()
|
||||||
elements_to_remove = []
|
elements_to_remove = []
|
||||||
while current:
|
while current:
|
||||||
# Stop at page break or next title
|
|
||||||
if current.name == 'span' and 'page-break' in str(current):
|
if current.name == 'span' and 'page-break' in str(current):
|
||||||
break
|
break
|
||||||
text = current.get_text() if hasattr(current, 'get_text') else str(current)
|
text = current.get_text() if hasattr(current, 'get_text') else str(current)
|
||||||
@@ -698,7 +332,6 @@ def main():
|
|||||||
if hasattr(elem, 'decompose'):
|
if hasattr(elem, 'decompose'):
|
||||||
elem.decompose()
|
elem.decompose()
|
||||||
|
|
||||||
# Insert new abstract content (english_text already contains <p> tags)
|
|
||||||
abstract_html = f'''{english_text}
|
abstract_html = f'''{english_text}
|
||||||
<p class=MsoNormal><span lang=EN-US><o:p> </o:p></span></p>
|
<p class=MsoNormal><span lang=EN-US><o:p> </o:p></span></p>
|
||||||
<p class=MsoNormal><b><span lang=EN-US>Keywords:</span></b><span lang=EN-US> {english_kw}</span></p>
|
<p class=MsoNormal><b><span lang=EN-US>Keywords:</span></b><span lang=EN-US> {english_kw}</span></p>
|
||||||
@@ -721,31 +354,24 @@ def main():
|
|||||||
for elem in soup.find_all(string=re.compile(r'Ejemplo de nota al pie')):
|
for elem in soup.find_all(string=re.compile(r'Ejemplo de nota al pie')):
|
||||||
parent = elem.parent
|
parent = elem.parent
|
||||||
if parent:
|
if parent:
|
||||||
# Find the footnote container and remove it
|
|
||||||
while parent and parent.name != 'p':
|
while parent and parent.name != 'p':
|
||||||
parent = parent.parent
|
parent = parent.parent
|
||||||
if parent:
|
if parent:
|
||||||
parent.decompose()
|
parent.decompose()
|
||||||
print(" ✓ Removed footnote example")
|
print(" ✓ Removed footnote example")
|
||||||
|
|
||||||
# Clear old figure/table index entries (they need to be regenerated in Word)
|
# Clear old figure/table index entries
|
||||||
print("Clearing old index entries...")
|
print("Clearing old index entries...")
|
||||||
|
|
||||||
# Remove ALL content from MsoTof paragraphs that reference template examples
|
|
||||||
# The indices will be regenerated when user opens in Word and presses Ctrl+A, F9
|
|
||||||
for p in soup.find_all('p', class_='MsoTof'):
|
for p in soup.find_all('p', class_='MsoTof'):
|
||||||
text = p.get_text()
|
text = p.get_text()
|
||||||
# Check for figure index entries with template examples
|
|
||||||
if 'Figura' in text and 'Ejemplo' in text:
|
if 'Figura' in text and 'Ejemplo' in text:
|
||||||
# Remove all <a> tags (the actual index entry links)
|
|
||||||
for a in p.find_all('a'):
|
for a in p.find_all('a'):
|
||||||
a.decompose()
|
a.decompose()
|
||||||
# Also remove any remaining text content that shows the example
|
|
||||||
for span in p.find_all('span', style=lambda x: x and 'mso-no-proof' in str(x)):
|
for span in p.find_all('span', style=lambda x: x and 'mso-no-proof' in str(x)):
|
||||||
if 'Ejemplo' in span.get_text():
|
if 'Ejemplo' in span.get_text():
|
||||||
span.decompose()
|
span.decompose()
|
||||||
print(" ✓ Cleared figure index example entry")
|
print(" ✓ Cleared figure index example entry")
|
||||||
# Check for table index entries with template examples
|
|
||||||
if 'Tabla' in text and 'Ejemplo' in text:
|
if 'Tabla' in text and 'Ejemplo' in text:
|
||||||
for a in p.find_all('a'):
|
for a in p.find_all('a'):
|
||||||
a.decompose()
|
a.decompose()
|
||||||
@@ -754,24 +380,20 @@ def main():
|
|||||||
span.decompose()
|
span.decompose()
|
||||||
print(" ✓ Cleared table index example entry")
|
print(" ✓ Cleared table index example entry")
|
||||||
|
|
||||||
# Remove old figure index entries that reference template examples
|
|
||||||
for p in soup.find_all('p', class_='MsoToc3'):
|
for p in soup.find_all('p', class_='MsoToc3'):
|
||||||
text = p.get_text()
|
text = p.get_text()
|
||||||
if 'Figura 1. Ejemplo' in text or 'Tabla 1. Ejemplo' in text:
|
if 'Figura 1. Ejemplo' in text or 'Tabla 1. Ejemplo' in text:
|
||||||
p.decompose()
|
p.decompose()
|
||||||
print(" ✓ Removed template index entry")
|
print(" ✓ Removed template index entry")
|
||||||
|
|
||||||
# Also clear the specific figure/table from template
|
|
||||||
for p in soup.find_all('p', class_='Imagencentrada'):
|
for p in soup.find_all('p', class_='Imagencentrada'):
|
||||||
p.decompose()
|
p.decompose()
|
||||||
print(" ✓ Removed template figure placeholder")
|
print(" ✓ Removed template figure placeholder")
|
||||||
|
|
||||||
# Remove template table example
|
# Remove template table example
|
||||||
for table in soup.find_all('table', class_='MsoTableGrid'):
|
for table in soup.find_all('table', class_='MsoTableGrid'):
|
||||||
# Check if this is the template example table
|
|
||||||
text = table.get_text()
|
text = table.get_text()
|
||||||
if 'Celda 1' in text or 'Encabezado 1' in text:
|
if 'Celda 1' in text or 'Encabezado 1' in text:
|
||||||
# Also remove surrounding caption and source
|
|
||||||
prev_sib = table.find_previous_sibling()
|
prev_sib = table.find_previous_sibling()
|
||||||
next_sib = table.find_next_sibling()
|
next_sib = table.find_next_sibling()
|
||||||
if prev_sib and 'Tabla 1. Ejemplo' in prev_sib.get_text():
|
if prev_sib and 'Tabla 1. Ejemplo' in prev_sib.get_text():
|
||||||
@@ -782,7 +404,7 @@ def main():
|
|||||||
print(" ✓ Removed template table example")
|
print(" ✓ Removed template table example")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Define chapters with their keywords and next chapter keywords
|
# Define chapters
|
||||||
chapters = [
|
chapters = [
|
||||||
('Introducción', 'intro', 'Contexto'),
|
('Introducción', 'intro', 'Contexto'),
|
||||||
('Contexto', 'contexto', 'Objetivos'),
|
('Contexto', 'contexto', 'Objetivos'),
|
||||||
@@ -795,16 +417,12 @@ def main():
|
|||||||
for chapter_keyword, doc_key, next_keyword in chapters:
|
for chapter_keyword, doc_key, next_keyword in chapters:
|
||||||
print(f" Processing: {chapter_keyword}")
|
print(f" Processing: {chapter_keyword}")
|
||||||
|
|
||||||
# Reset counters for consistent numbering per chapter (optional - remove if you want global numbering)
|
|
||||||
# table_counter = 0
|
|
||||||
# figure_counter = 0
|
|
||||||
|
|
||||||
start_elem = find_section_element(soup, chapter_keyword)
|
start_elem = find_section_element(soup, chapter_keyword)
|
||||||
end_elem = find_section_element(soup, next_keyword)
|
end_elem = find_section_element(soup, next_keyword)
|
||||||
|
|
||||||
if start_elem and end_elem:
|
if start_elem and end_elem:
|
||||||
remove_elements_between(start_elem, end_elem)
|
remove_elements_between(start_elem, end_elem)
|
||||||
new_content_html = extract_section_content(docs[doc_key])
|
new_content_html, counters = extract_section_content(docs[doc_key], counters=counters)
|
||||||
new_soup = BeautifulSoup(new_content_html, 'html.parser')
|
new_soup = BeautifulSoup(new_content_html, 'html.parser')
|
||||||
insert_point = start_elem
|
insert_point = start_elem
|
||||||
for new_elem in reversed(list(new_soup.children)):
|
for new_elem in reversed(list(new_soup.children)):
|
||||||
@@ -844,20 +462,20 @@ def main():
|
|||||||
current.extract()
|
current.extract()
|
||||||
current = next_elem
|
current = next_elem
|
||||||
|
|
||||||
anexo_content = extract_section_content(docs['anexo'], is_anexo=True)
|
anexo_content, counters = extract_section_content(docs['anexo'], is_anexo=True, counters=counters)
|
||||||
anexo_soup = BeautifulSoup(anexo_content, 'html.parser')
|
anexo_soup = BeautifulSoup(anexo_content, 'html.parser')
|
||||||
insert_point = anexo_elem
|
insert_point = anexo_elem
|
||||||
for new_elem in reversed(list(anexo_soup.children)):
|
for new_elem in reversed(list(anexo_soup.children)):
|
||||||
insert_point.insert_after(new_elem)
|
insert_point.insert_after(new_elem)
|
||||||
print(f" ✓ Replaced content")
|
print(f" ✓ Replaced content")
|
||||||
|
|
||||||
print(f"\nSummary: {table_counter} tables + {anexo_table_counter} Anexo tables, {figure_counter} figures + {anexo_figure_counter} Anexo figures processed")
|
print(f"\nSummary: {counters['table']} tables + {counters['anexo_table']} Anexo tables, {counters['figure']} figures + {counters['anexo_figure']} Anexo figures processed")
|
||||||
|
|
||||||
print("Saving modified template...")
|
print("Saving modified template...")
|
||||||
output_html = str(soup)
|
output_html = str(soup)
|
||||||
write_file(TEMPLATE_OUTPUT, output_html)
|
write_file(TEMPLATE_OUTPUT, output_html)
|
||||||
|
|
||||||
# Copy template support files (header.htm, images, etc.)
|
# Copy template support files
|
||||||
support_files_src = os.path.join(BASE_DIR, 'instructions/plantilla_individual_files')
|
support_files_src = os.path.join(BASE_DIR, 'instructions/plantilla_individual_files')
|
||||||
support_files_dst = os.path.join(BASE_DIR, 'thesis_output/plantilla_individual_files')
|
support_files_dst = os.path.join(BASE_DIR, 'thesis_output/plantilla_individual_files')
|
||||||
if os.path.exists(support_files_src):
|
if os.path.exists(support_files_src):
|
||||||
@@ -874,5 +492,6 @@ def main():
|
|||||||
print(" - This will regenerate: Índice de contenidos, Índice de figuras, Índice de tablas")
|
print(" - This will regenerate: Índice de contenidos, Índice de figuras, Índice de tablas")
|
||||||
print("4. Save as .docx")
|
print("4. Save as .docx")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
|||||||
11
claude.md
11
claude.md
@@ -77,7 +77,9 @@ MastersThesis/
|
|||||||
│ ├── instrucciones.pdf # TFE writing guidelines
|
│ ├── instrucciones.pdf # TFE writing guidelines
|
||||||
│ ├── plantilla_individual.pdf # Word template (PDF version)
|
│ ├── plantilla_individual.pdf # Word template (PDF version)
|
||||||
│ └── plantilla_individual.htm # Word template (HTML version, source)
|
│ └── plantilla_individual.htm # Word template (HTML version, source)
|
||||||
├── apply_content.py # Generates TFM document from docs/ + template
|
├── apply_content.py # Main orchestrator: generates TFM from docs/ + template
|
||||||
|
├── content_handlers.py # Content block handlers (tables, figures, lists, etc.)
|
||||||
|
├── markdown_utils.py # Markdown utilities (cross-refs, latex, metadata extraction)
|
||||||
├── generate_mermaid_figures.py # Converts Mermaid diagrams to PNG
|
├── generate_mermaid_figures.py # Converts Mermaid diagrams to PNG
|
||||||
├── src/archived/ocr_benchmark_notebook.ipynb # Archived benchmark (do not cite)
|
├── src/archived/ocr_benchmark_notebook.ipynb # Archived benchmark (do not cite)
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -162,6 +164,13 @@ python3 apply_content.py
|
|||||||
```
|
```
|
||||||
|
|
||||||
**What `apply_content.py` does:**
|
**What `apply_content.py` does:**
|
||||||
|
|
||||||
|
The script is organized into three modules for maintainability:
|
||||||
|
- `apply_content.py` - Main orchestrator (~300 lines)
|
||||||
|
- `content_handlers.py` - Block-level content handlers (~400 lines)
|
||||||
|
- `markdown_utils.py` - Utility functions (~150 lines)
|
||||||
|
|
||||||
|
Functionality:
|
||||||
- Replaces Resumen and Abstract with actual content + keywords
|
- Replaces Resumen and Abstract with actual content + keywords
|
||||||
- Replaces all 5 chapters with content from docs/
|
- Replaces all 5 chapters with content from docs/
|
||||||
- Replaces Referencias with APA-formatted bibliography
|
- Replaces Referencias with APA-formatted bibliography
|
||||||
|
|||||||
469
content_handlers.py
Normal file
469
content_handlers.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Content block handlers for markdown to HTML conversion."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from markdown_utils import (
|
||||||
|
md_to_html_para,
|
||||||
|
convert_latex_formulas,
|
||||||
|
is_source_line,
|
||||||
|
extract_source_from_line,
|
||||||
|
is_leyenda_line,
|
||||||
|
extract_leyenda_from_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Base directory for resolving paths
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_mermaid_diagram(lines, i, counters, is_anexo):
|
||||||
|
"""Handle mermaid diagram block, converting to figure with image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to ```mermaid)
|
||||||
|
counters: Dict with 'table', 'figure', 'anexo_table', 'anexo_figure', 'global_figure'
|
||||||
|
is_anexo: Boolean indicating if processing Anexo section
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
|
||||||
|
# Always increment global index for sequential filenames
|
||||||
|
counters['global_figure'] += 1
|
||||||
|
|
||||||
|
# Use Anexo-specific counter with "A" prefix for display, or global counter
|
||||||
|
if is_anexo:
|
||||||
|
counters['anexo_figure'] += 1
|
||||||
|
fig_num = f"A{counters['anexo_figure']}"
|
||||||
|
else:
|
||||||
|
counters['figure'] += 1
|
||||||
|
fig_num = str(counters['figure'])
|
||||||
|
|
||||||
|
mermaid_lines = []
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and not lines[i].strip() == '```':
|
||||||
|
mermaid_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Try to extract title from mermaid content (YAML format)
|
||||||
|
mermaid_content = '\n'.join(mermaid_lines)
|
||||||
|
# Match title with quotes: title: "Something" or title: 'Something'
|
||||||
|
title_match = re.search(r'title:\s*["\']([^"\']+)["\']', mermaid_content)
|
||||||
|
if not title_match:
|
||||||
|
# Match title without quotes: title: Something
|
||||||
|
title_match = re.search(r'title:\s*([^"\'\n]+)', mermaid_content)
|
||||||
|
if title_match:
|
||||||
|
fig_title = title_match.group(1).strip()
|
||||||
|
else:
|
||||||
|
fig_title = f"Diagrama {fig_num}"
|
||||||
|
|
||||||
|
# Use global sequential index for filename
|
||||||
|
fig_file = f'figures/figura_{counters["global_figure"]}.png'
|
||||||
|
fig_path = os.path.join(BASE_DIR, 'thesis_output', fig_file)
|
||||||
|
|
||||||
|
# Create figure with MsoCaption class and proper Word SEQ field
|
||||||
|
bookmark_id = f"_Ref_Fig{fig_num}"
|
||||||
|
|
||||||
|
if is_anexo:
|
||||||
|
tc_field = f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> TC "Figura {fig_num}. {fig_title}" \\f c \\l 1 <span style='mso-element:field-end'></span><![endif]-->'''
|
||||||
|
html_blocks.append(f'''<a name="{bookmark_id}"></a>{tc_field}<p class=MsoCaption style="text-align:center;mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Figura {fig_num}.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{fig_title}</span></i></p>''')
|
||||||
|
else:
|
||||||
|
html_blocks.append(f'''<a name="{bookmark_id}"></a><p class=MsoCaption style="text-align:center;mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Figura <!--[if supportFields]><span style='mso-element:field-begin'></span> SEQ Figura \\* ARABIC <span style='mso-element:field-separator'></span><![endif]-->{fig_num}<!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{fig_title}</span></i></p>''')
|
||||||
|
|
||||||
|
if os.path.exists(fig_path):
|
||||||
|
# Read actual image dimensions and scale to fit page width
|
||||||
|
img = Image.open(fig_path)
|
||||||
|
orig_w, orig_h = img.size
|
||||||
|
|
||||||
|
# Scale to fit max width of 566px (15cm at 96dpi) while preserving aspect ratio
|
||||||
|
max_width = 566
|
||||||
|
if orig_w > max_width:
|
||||||
|
scale = max_width / orig_w
|
||||||
|
new_w = max_width
|
||||||
|
new_h = int(orig_h * scale)
|
||||||
|
else:
|
||||||
|
new_w, new_h = orig_w, orig_h
|
||||||
|
|
||||||
|
# Convert to pt (1px at 96dpi = 0.75pt)
|
||||||
|
w_pt = new_w * 0.75
|
||||||
|
h_pt = new_h * 0.75
|
||||||
|
|
||||||
|
html_blocks.append(f'''<p class=MsoNormal style="text-align:center;mso-pagination:keep-with-next"><span lang=ES><img width="{new_w}" height="{new_h}" style="width:{w_pt}pt;height:{h_pt}pt;display:block;margin:0 auto" src="{fig_file}" alt="{fig_title}"/></span></p>''')
|
||||||
|
else:
|
||||||
|
# Fallback to placeholder
|
||||||
|
html_blocks.append(f'''<p class=MsoNormal style="text-align:center;mso-pagination:keep-with-next;border:1px dashed #999;padding:20px;margin:10px 40px;background:#f9f9f9"><span lang=ES style="color:#666">[Insertar diagrama Mermaid aquí]</span></p>''')
|
||||||
|
|
||||||
|
# Check if next non-empty line has custom Fuente
|
||||||
|
custom_source = None
|
||||||
|
fig_leyenda = None
|
||||||
|
lookahead = i + 1
|
||||||
|
while lookahead < len(lines) and not lines[lookahead].strip():
|
||||||
|
lookahead += 1
|
||||||
|
if lookahead < len(lines):
|
||||||
|
next_line = lines[lookahead].strip()
|
||||||
|
if is_source_line(next_line):
|
||||||
|
custom_source = extract_source_from_line(next_line)
|
||||||
|
if custom_source and not custom_source.endswith('.'):
|
||||||
|
custom_source += '.'
|
||||||
|
i = lookahead
|
||||||
|
# Check for Leyenda after source
|
||||||
|
leyenda_idx = i + 1
|
||||||
|
while leyenda_idx < len(lines) and not lines[leyenda_idx].strip():
|
||||||
|
leyenda_idx += 1
|
||||||
|
if leyenda_idx < len(lines) and is_leyenda_line(lines[leyenda_idx]):
|
||||||
|
fig_leyenda = extract_leyenda_from_line(lines[leyenda_idx])
|
||||||
|
i = leyenda_idx
|
||||||
|
|
||||||
|
if custom_source:
|
||||||
|
source_html = md_to_html_para(custom_source)
|
||||||
|
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Fuente: {source_html}</span></p>''')
|
||||||
|
else:
|
||||||
|
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Fuente: Elaboración propia.</span></p>''')
|
||||||
|
|
||||||
|
if fig_leyenda:
|
||||||
|
leyenda_html = md_to_html_para(fig_leyenda)
|
||||||
|
if not fig_leyenda.endswith('.'):
|
||||||
|
leyenda_html += '.'
|
||||||
|
html_blocks.append(f'''<p class=Piedefoto-tabla style="margin-left:0cm;text-align:center"><span lang=ES>Leyenda: {leyenda_html}</span></p>''')
|
||||||
|
|
||||||
|
html_blocks.append('<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>')
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return html_blocks, i
|
||||||
|
|
||||||
|
|
||||||
|
def handle_code_block(lines, i):
|
||||||
|
"""Handle non-mermaid code block.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to ```)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
code_lang = lines[i].strip()[3:]
|
||||||
|
code_lines = []
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and not lines[i].strip().startswith('```'):
|
||||||
|
code_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
code = '\n'.join(code_lines)
|
||||||
|
# Escape HTML entities in code
|
||||||
|
code = code.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
html_blocks.append(f'''<div style="background:#E6F4F9;border-top:solid #0098CD .5pt;border-bottom:solid #0098CD .5pt;padding:8pt 12pt;margin:6pt 0">
|
||||||
|
<pre style="font-family:Consolas,monospace;font-size:9pt;color:#333333;margin:0;white-space:pre-wrap;word-wrap:break-word">{code}</pre>
|
||||||
|
</div>''')
|
||||||
|
i += 1
|
||||||
|
return html_blocks, i
|
||||||
|
|
||||||
|
|
||||||
|
def handle_header(line, is_anexo):
|
||||||
|
"""Handle header lines (##, ###, ####).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: The header line
|
||||||
|
is_anexo: Boolean indicating if processing Anexo section
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML string for the header, or None if h1 (skip)
|
||||||
|
"""
|
||||||
|
if line.startswith('####'):
|
||||||
|
text = line.lstrip('#').strip()
|
||||||
|
return f'<h4 style="mso-list:none"><b><span lang=ES style="text-transform:none">{text}</span></b></h4>'
|
||||||
|
elif line.startswith('###'):
|
||||||
|
text = line.lstrip('#').strip()
|
||||||
|
# Extract section number if present
|
||||||
|
sec_match = re.match(r'^([\d\.]+)\s+', text)
|
||||||
|
bookmark_html = ''
|
||||||
|
if sec_match:
|
||||||
|
sec_num = sec_match.group(1).rstrip('.')
|
||||||
|
bookmark_id = f"_Ref_Sec{sec_num.replace('.', '_')}"
|
||||||
|
bookmark_html = f'<a name="{bookmark_id}"></a>'
|
||||||
|
# Disable auto-numbering for Anexo content or A.x headings
|
||||||
|
if is_anexo or re.match(r'^A\.\d+', text):
|
||||||
|
return f'{bookmark_html}<h3 style="mso-list:none"><span lang=ES style="text-transform:none">{text}</span></h3>'
|
||||||
|
else:
|
||||||
|
return f'{bookmark_html}<h3 style="mso-list:l22 level3 lfo18"><span lang=ES style="text-transform:none">{text}</span></h3>'
|
||||||
|
elif line.startswith('##'):
|
||||||
|
text = line.lstrip('#').strip()
|
||||||
|
# Extract section number if present
|
||||||
|
sec_match = re.match(r'^([\d\.]+)\s+', text)
|
||||||
|
bookmark_html = ''
|
||||||
|
if sec_match:
|
||||||
|
sec_num = sec_match.group(1).rstrip('.')
|
||||||
|
bookmark_id = f"_Ref_Sec{sec_num.replace('.', '_')}"
|
||||||
|
bookmark_html = f'<a name="{bookmark_id}"></a>'
|
||||||
|
# Disable auto-numbering for Anexo content or A.x headings
|
||||||
|
if is_anexo or re.match(r'^A\.\d+', text):
|
||||||
|
return f'{bookmark_html}<h2 style="mso-list:none"><span lang=ES style="text-transform:none">{text}</span></h2>'
|
||||||
|
else:
|
||||||
|
return f'{bookmark_html}<h2 style="mso-list:l22 level2 lfo18"><span lang=ES style="text-transform:none">{text}</span></h2>'
|
||||||
|
elif line.startswith('#'):
|
||||||
|
# Skip h1 - we keep the original
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_table(lines, i, counters, is_anexo):
|
||||||
|
"""Handle markdown table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to first table row)
|
||||||
|
counters: Dict with table/figure counters
|
||||||
|
is_anexo: Boolean indicating if processing Anexo section
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
|
||||||
|
# Use Anexo-specific counter with "A" prefix, or global counter
|
||||||
|
if is_anexo:
|
||||||
|
counters['anexo_table'] += 1
|
||||||
|
table_num = f"A{counters['anexo_table']}"
|
||||||
|
else:
|
||||||
|
counters['table'] += 1
|
||||||
|
table_num = str(counters['table'])
|
||||||
|
|
||||||
|
# Check if previous line has table title
|
||||||
|
table_title = None
|
||||||
|
alt_title = None
|
||||||
|
table_source = "Elaboración propia"
|
||||||
|
|
||||||
|
# Look back for table title
|
||||||
|
for j in range(i - 1, max(0, i - 5), -1):
|
||||||
|
prev_line = lines[j].strip()
|
||||||
|
if prev_line.startswith('**Tabla') or prev_line.startswith('*Tabla'):
|
||||||
|
table_title = re.sub(r'\*+', '', prev_line).strip()
|
||||||
|
break
|
||||||
|
elif prev_line.startswith('**') and prev_line.endswith(':**'):
|
||||||
|
alt_title = re.sub(r'\*+', '', prev_line).rstrip(':').strip()
|
||||||
|
elif prev_line and not prev_line.startswith('|'):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse table
|
||||||
|
table_lines = []
|
||||||
|
while i < len(lines) and '|' in lines[i]:
|
||||||
|
if '---' not in lines[i]:
|
||||||
|
table_lines.append(lines[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Look ahead for source
|
||||||
|
source_idx = i
|
||||||
|
table_leyenda = None
|
||||||
|
while source_idx < len(lines) and not lines[source_idx].strip():
|
||||||
|
source_idx += 1
|
||||||
|
if source_idx < len(lines) and is_source_line(lines[source_idx]):
|
||||||
|
table_source = extract_source_from_line(lines[source_idx])
|
||||||
|
i = source_idx + 1
|
||||||
|
# Check for Leyenda after source
|
||||||
|
leyenda_idx = i
|
||||||
|
while leyenda_idx < len(lines) and not lines[leyenda_idx].strip():
|
||||||
|
leyenda_idx += 1
|
||||||
|
if leyenda_idx < len(lines) and is_leyenda_line(lines[leyenda_idx]):
|
||||||
|
table_leyenda = extract_leyenda_from_line(lines[leyenda_idx])
|
||||||
|
i = leyenda_idx + 1
|
||||||
|
|
||||||
|
# Add table title with MsoCaption class
|
||||||
|
bookmark_id = f"_Ref_Tab{table_num}"
|
||||||
|
if table_title:
|
||||||
|
clean_title = re.sub(r'^Tabla\s+[A-Z]?\d+\.\s*', '', table_title).strip()
|
||||||
|
elif alt_title:
|
||||||
|
clean_title = alt_title
|
||||||
|
else:
|
||||||
|
clean_title = "Tabla de datos."
|
||||||
|
|
||||||
|
if is_anexo:
|
||||||
|
tc_field = f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> TC "Tabla {table_num}. {clean_title}" \\f t \\l 1 <span style='mso-element:field-end'></span><![endif]-->'''
|
||||||
|
html_blocks.append(f'''<a name="{bookmark_id}"></a>{tc_field}<p class=MsoCaption style="mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Tabla {table_num}.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{clean_title}</span></i></p>''')
|
||||||
|
else:
|
||||||
|
html_blocks.append(f'''<a name="{bookmark_id}"></a><p class=MsoCaption style="mso-pagination:keep-with-next"><b><span lang=ES style="font-size:12.0pt;line-height:150%">Tabla <!--[if supportFields]><span style='mso-element:field-begin'></span> SEQ Tabla \\* ARABIC <span style='mso-element:field-separator'></span><![endif]-->{table_num}<!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->.</span></b><span lang=ES style="font-size:12.0pt;line-height:150%"> </span><i><span lang=ES style="font-size:12.0pt;line-height:150%">{clean_title}</span></i></p>''')
|
||||||
|
|
||||||
|
# Build table HTML with APA style
|
||||||
|
table_html = '<div align="center"><table class=MsoTableGrid border=1 cellspacing=0 cellpadding=0 align="center" style="border-collapse:collapse;margin-left:auto;margin-right:auto;mso-table-style-name:\'Plain Table 1\'">'
|
||||||
|
for j, tline in enumerate(table_lines):
|
||||||
|
cells = [c.strip() for c in tline.split('|')[1:-1]]
|
||||||
|
table_html += '<tr>'
|
||||||
|
for cell in cells:
|
||||||
|
if j == 0:
|
||||||
|
# Header row
|
||||||
|
table_html += f'<td style="border-top:solid windowtext 1.0pt;border-bottom:solid windowtext 1.0pt;border-left:none;border-right:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><b><span lang=ES>{md_to_html_para(cell)}</span></b></p></td>'
|
||||||
|
elif j == len(table_lines) - 1:
|
||||||
|
# Last row
|
||||||
|
table_html += f'<td style="border-top:none;border-bottom:solid windowtext 1.0pt;border-left:none;border-right:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><span lang=ES>{md_to_html_para(cell)}</span></p></td>'
|
||||||
|
else:
|
||||||
|
# Middle rows
|
||||||
|
table_html += f'<td style="border:none;padding:5px"><p class=MsoNormal style="margin:0;text-align:center"><span lang=ES>{md_to_html_para(cell)}</span></p></td>'
|
||||||
|
table_html += '</tr>'
|
||||||
|
table_html += '</table></div>'
|
||||||
|
html_blocks.append(table_html)
|
||||||
|
|
||||||
|
# Add source
|
||||||
|
source_html = md_to_html_para(table_source)
|
||||||
|
if not table_source.endswith('.'):
|
||||||
|
source_html += '.'
|
||||||
|
html_blocks.append(f'<p class=Piedefoto-tabla style="margin-left:0cm"><span lang=ES>Fuente: {source_html}</span></p>')
|
||||||
|
|
||||||
|
# Add leyenda if present
|
||||||
|
if table_leyenda:
|
||||||
|
leyenda_html = md_to_html_para(table_leyenda)
|
||||||
|
if not table_leyenda.endswith('.'):
|
||||||
|
leyenda_html += '.'
|
||||||
|
html_blocks.append(f'<p class=Piedefoto-tabla style="margin-left:0cm"><span lang=ES>Leyenda: {leyenda_html}</span></p>')
|
||||||
|
|
||||||
|
html_blocks.append('<p class=MsoNormal><span lang=ES><o:p> </o:p></span></p>')
|
||||||
|
|
||||||
|
return html_blocks, i
|
||||||
|
|
||||||
|
|
||||||
|
def handle_blockquote(lines, i):
|
||||||
|
"""Handle blockquote (regular or Nota callout).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to > line)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
line = lines[i]
|
||||||
|
quote_text = line[1:].strip()
|
||||||
|
i += 1
|
||||||
|
while i < len(lines) and lines[i].startswith('>'):
|
||||||
|
quote_text += ' ' + lines[i][1:].strip()
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Check if this is a Nota/Note callout
|
||||||
|
if quote_text.startswith('**Nota:**') or quote_text.startswith('**Note:**'):
|
||||||
|
if quote_text.startswith('**Nota:**'):
|
||||||
|
label = 'Nota:'
|
||||||
|
content = quote_text[9:].strip()
|
||||||
|
else:
|
||||||
|
label = 'Note:'
|
||||||
|
content = quote_text[9:].strip()
|
||||||
|
|
||||||
|
# UNIR callout box style
|
||||||
|
html_blocks.append(f'''<div style='mso-element:para-border-div;border-top:solid #0098CD 1.0pt;border-left:none;border-bottom:solid #0098CD 1.0pt;border-right:none;mso-border-top-alt:solid #0098CD .5pt;mso-border-bottom-alt:solid #0098CD .5pt;padding:4.0pt 0cm 4.0pt 0cm;background:#E6F4F9'>
|
||||||
|
<p class=MsoNormal style='background:#E6F4F9;border:none;padding:0cm;margin:0cm'><b><span lang=ES>{label}</span></b><span lang=ES> {md_to_html_para(content)}</span></p>
|
||||||
|
</div>''')
|
||||||
|
else:
|
||||||
|
# Regular blockquote
|
||||||
|
html_blocks.append(f'<p class=MsoQuote><i><span lang=ES>{md_to_html_para(quote_text)}</span></i></p>')
|
||||||
|
|
||||||
|
return html_blocks, i
|
||||||
|
|
||||||
|
|
||||||
|
def handle_bullet_list(lines, i):
|
||||||
|
"""Handle bullet list (-, *, +).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to first bullet)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
bullet_items = []
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
# Skip blank lines
|
||||||
|
while i < len(lines) and not lines[i].strip():
|
||||||
|
i += 1
|
||||||
|
# Check if next non-blank line is a bullet item
|
||||||
|
if i < len(lines) and re.match(r'^[\-\*\+]\s', lines[i]):
|
||||||
|
item_text = lines[i][2:].strip()
|
||||||
|
item_text = convert_latex_formulas(item_text)
|
||||||
|
bullet_items.append(md_to_html_para(item_text))
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Output with proper First/Middle/Last classes
|
||||||
|
for idx, item in enumerate(bullet_items):
|
||||||
|
if len(bullet_items) == 1:
|
||||||
|
cls = 'MsoListParagraph'
|
||||||
|
elif idx == 0:
|
||||||
|
cls = 'MsoListParagraphCxSpFirst'
|
||||||
|
elif idx == len(bullet_items) - 1:
|
||||||
|
cls = 'MsoListParagraphCxSpLast'
|
||||||
|
else:
|
||||||
|
cls = 'MsoListParagraphCxSpMiddle'
|
||||||
|
html_blocks.append(f'<p class={cls} style="margin-left:36pt;text-indent:-18pt"><span lang=ES style="font-family:Symbol">·</span><span lang=ES style="font-size:7pt"> </span><span lang=ES>{item}</span></p>')
|
||||||
|
|
||||||
|
return html_blocks, i
|
||||||
|
|
||||||
|
|
||||||
|
def handle_numbered_list(lines, i):
|
||||||
|
"""Handle numbered list (1., 2., etc).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of markdown lines
|
||||||
|
i: Current line index (pointing to first numbered item)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (html_blocks, new_index)
|
||||||
|
"""
|
||||||
|
html_blocks = []
|
||||||
|
numbered_items = []
|
||||||
|
|
||||||
|
while i < len(lines):
|
||||||
|
# Skip blank lines
|
||||||
|
while i < len(lines) and not lines[i].strip():
|
||||||
|
i += 1
|
||||||
|
# Check if next non-blank line is a numbered item
|
||||||
|
if i < len(lines) and re.match(r'^\d+\.\s', lines[i]):
|
||||||
|
item_text = re.sub(r'^\d+\.\s*', '', lines[i]).strip()
|
||||||
|
i += 1
|
||||||
|
# Collect any nested/indented content
|
||||||
|
nested_lines = []
|
||||||
|
while i < len(lines):
|
||||||
|
current = lines[i]
|
||||||
|
# Stop conditions
|
||||||
|
if re.match(r'^\d+\.\s', current):
|
||||||
|
break
|
||||||
|
if current.startswith('#'):
|
||||||
|
break
|
||||||
|
if current.startswith('```'):
|
||||||
|
break
|
||||||
|
if current.startswith('**Tabla') or current.startswith('**Figura'):
|
||||||
|
break
|
||||||
|
if current.strip() and not current.startswith(' ') and not current.startswith('\t') and not current.startswith('-'):
|
||||||
|
if nested_lines or not current.strip():
|
||||||
|
break
|
||||||
|
if current.strip():
|
||||||
|
cleaned = current.strip()
|
||||||
|
if cleaned.startswith('- '):
|
||||||
|
cleaned = cleaned[2:]
|
||||||
|
nested_lines.append(cleaned)
|
||||||
|
i += 1
|
||||||
|
# Combine item with nested content
|
||||||
|
if nested_lines:
|
||||||
|
item_text = item_text + '<br/>' + '<br/>'.join(nested_lines)
|
||||||
|
item_text = convert_latex_formulas(item_text)
|
||||||
|
numbered_items.append(md_to_html_para(item_text))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Output with proper First/Middle/Last classes
|
||||||
|
for idx, item in enumerate(numbered_items):
|
||||||
|
num = idx + 1
|
||||||
|
if len(numbered_items) == 1:
|
||||||
|
cls = 'MsoListParagraph'
|
||||||
|
elif idx == 0:
|
||||||
|
cls = 'MsoListParagraphCxSpFirst'
|
||||||
|
elif idx == len(numbered_items) - 1:
|
||||||
|
cls = 'MsoListParagraphCxSpLast'
|
||||||
|
else:
|
||||||
|
cls = 'MsoListParagraphCxSpMiddle'
|
||||||
|
html_blocks.append(f'<p class={cls} style="margin-left:36pt;text-indent:-18pt"><span lang=ES>{num}.<span style="font-size:7pt"> </span>{item}</span></p>')
|
||||||
|
|
||||||
|
return html_blocks, i
|
||||||
@@ -1,29 +1,29 @@
|
|||||||
# Resumen
|
# Resumen
|
||||||
|
|
||||||
El presente Trabajo Fin de Máster aborda la optimización de sistemas de Reconocimiento Óptico de Caracteres (OCR) basados en inteligencia artificial para documentos en español. El objetivo principal es identificar la configuración óptima de hiperparámetros que maximice la precisión del reconocimiento de texto sin requerir fine-tuning de los modelos base.
|
El presente Trabajo Fin de Máster aborda la optimización de sistemas de Reconocimiento Óptico de Caracteres (OCR) basados en inteligencia artificial para documentos en español. El objetivo principal es identificar una configuración de hiperparámetros que maximice la precisión del reconocimiento de texto sin requerir fine-tuning de los modelos base.
|
||||||
|
|
||||||
Se realizó un estudio comparativo de tres soluciones OCR de código abierto: EasyOCR, PaddleOCR (PP-OCRv5) y DocTR. Se evaluó su rendimiento mediante las métricas estándar CER (Character Error Rate) y WER (Word Error Rate) sobre un corpus de 45 páginas de documentos académicos en español. Tras identificar PaddleOCR como la solución más prometedora, se procedió a una optimización sistemática de hiperparámetros utilizando Ray Tune con el algoritmo de búsqueda Optuna, ejecutando 64 configuraciones diferentes con aceleración GPU (NVIDIA RTX 3060).
|
La metodología combina un benchmark comparativo de tres soluciones de código abierto (EasyOCR, PaddleOCR y DocTR) con un ajuste sistemático de hiperparámetros mediante Ray Tune y Optuna, evaluando 64 configuraciones con aceleración GPU sobre un corpus de 45 páginas. Las métricas de evaluación utilizadas fueron CER y WER.
|
||||||
|
|
||||||
Los resultados demuestran que la optimización de hiperparámetros logró mejoras significativas: el mejor trial individual alcanzó un CER de 0.79% (precisión del 99.21%), cumpliendo el objetivo de CER < 2%. Al validar la configuración optimizada sobre el dataset completo de 45 páginas, se obtuvo una mejora del 12.8% en CER (de 8.85% a 7.72%). El hallazgo más relevante fue que el parámetro `textline_orientation` (clasificación de orientación de línea de texto) tiene un impacto crítico en el rendimiento. Adicionalmente, se identificó que el umbral de detección (`text_det_thresh`) presenta una correlación positiva moderada (0.43) con el error, lo que indica que valores más bajos tienden a mejorar el rendimiento.
|
Los resultados muestran mejoras significativas en el mejor trial (CER 0.79%) y una mejora del 12.8% en CER en la validación sobre el dataset completo (de 8.85% a 7.72%). El parámetro `textline_orientation` destacó como factor crítico, mientras que `text_det_thresh` mostró correlación positiva moderada con el error.
|
||||||
|
|
||||||
|
Se concluye que la optimización de hiperparámetros es una alternativa viable al fine-tuning en documentos académicos en español, aunque la generalización depende del tamaño del subconjunto de ajuste. En conclusión, la infraestructura dockerizada facilita la reproducibilidad y la evaluación sistemática de configuraciones OCR.
|
||||||
|
|
||||||
**Fuente:** [`metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md), [`paddle_correlations.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/correlations/paddle_correlations.csv).
|
**Fuente:** [`metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md), [`paddle_correlations.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/correlations/paddle_correlations.csv).
|
||||||
|
|
||||||
Este trabajo demuestra que la optimización de hiperparámetros es una alternativa viable al fine-tuning, especialmente útil cuando se dispone de modelos preentrenados para el idioma objetivo. La infraestructura dockerizada desarrollada permite reproducir los experimentos y facilita la evaluación sistemática de configuraciones OCR.
|
**Palabras clave:** OCR, PaddleOCR, Optimización de hiperparámetros, Ray Tune, Documentos académicos
|
||||||
|
|
||||||
**Palabras clave:** OCR, Reconocimiento Óptico de Caracteres, PaddleOCR, Optimización de Hiperparámetros, Ray Tune, Procesamiento de Documentos, Inteligencia Artificial
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Abstract
|
# Abstract
|
||||||
|
|
||||||
This Master's Thesis addresses the optimization of Artificial Intelligence-based Optical Character Recognition (OCR) systems for Spanish documents. The main objective is to identify the optimal hyperparameter configuration that maximizes text recognition accuracy without requiring fine-tuning of the base models.
|
This Master's Thesis addresses the optimization of AI-based Optical Character Recognition (OCR) systems for Spanish academic documents. The main objective is to identify a hyperparameter configuration that maximizes recognition accuracy without fine-tuning the base models.
|
||||||
|
|
||||||
A comparative study of three open-source OCR solutions was conducted with EasyOCR, PaddleOCR (PP-OCRv5), and DocTR. Their performance was evaluated using standard CER (Character Error Rate) and WER (Word Error Rate) metrics on a corpus of 45 pages of academic documents in Spanish. After identifying PaddleOCR as the most promising solution, systematic hyperparameter optimization was performed using Ray Tune with the Optuna search algorithm, executing 64 different configurations with GPU acceleration (NVIDIA RTX 3060).
|
The methodology combines a comparative benchmark of three open-source OCR engines (EasyOCR, PaddleOCR, and DocTR) with a systematic hyperparameter search using Ray Tune and Optuna. Sixty-four configurations were evaluated with GPU acceleration on a 45-page corpus, using CER and WER as evaluation metrics.
|
||||||
|
|
||||||
Results demonstrate that hyperparameter optimization achieved significant improvements. The best individual trial reached a CER of 0.79% (99.21% accuracy), meeting the CER < 2% objective. When validating the optimized configuration on the full 45-page dataset, a 12.8% CER improvement was obtained (from 8.85% to 7.72%). The most relevant finding was that the `textline_orientation` parameter (text line orientation classification) has a critical impact on performance. Additionally, the detection threshold (`text_det_thresh`) showed a moderate positive correlation (0.43) with error, indicating that lower values tend to improve performance.
|
Results show significant gains in the best trial (CER 0.79%) and a 12.8% CER improvement on the full dataset (from 8.85% to 7.72%). The `textline_orientation` parameter had the strongest impact, while `text_det_thresh` showed a moderate positive correlation with error.
|
||||||
|
|
||||||
|
The study concludes that hyperparameter optimization is a viable alternative to fine-tuning for Spanish academic documents, although generalization depends on the size of the tuning subset. In conclusion, the dockerized infrastructure supports reproducibility and systematic evaluation of OCR configurations.
|
||||||
|
|
||||||
Sources: [`metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md), [`paddle_correlations.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/correlations/paddle_correlations.csv).
|
Sources: [`metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md), [`paddle_correlations.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/correlations/paddle_correlations.csv).
|
||||||
|
|
||||||
This work demonstrates that hyperparameter optimization is a viable alternative to fine-tuning, especially useful when pre-trained models for the target language are available. The dockerized infrastructure developed enables experiment reproducibility and facilitates systematic evaluation of OCR configurations.
|
**Keywords:** OCR, PaddleOCR, Hyperparameter optimization, Ray Tune, Academic documents
|
||||||
|
|
||||||
**Keywords:** OCR, Optical Character Recognition, PaddleOCR, Hyperparameter Optimization, Ray Tune, Document Processing, Artificial Intelligence
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Introducción
|
# Introducción
|
||||||
|
|
||||||
¿Es posible mejorar significativamente un sistema OCR sin reentrenarlo? Esta pregunta, aparentemente simple, encierra un desafío práctico que afecta a investigadores, instituciones educativas y empresas que necesitan digitalizar documentos pero carecen de los recursos para realizar fine-tuning de modelos neuronales. A lo largo de este capítulo se desarrolla la motivación del trabajo, se identifica el problema a resolver y se plantean las preguntas de investigación que guiarán el desarrollo experimental.
|
¿Es posible mejorar significativamente un sistema OCR sin reentrenarlo? Esta pregunta, aparentemente simple, encierra un desafío práctico que afecta a investigadores, instituciones educativas y empresas que necesitan digitalizar documentos pero carecen de los recursos para realizar fine-tuning de modelos neuronales.
|
||||||
|
|
||||||
## Motivación
|
## Motivación
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ La presente investigación surge de una necesidad práctica: optimizar un sistem
|
|||||||
|
|
||||||
La hipótesis central de este trabajo es que los modelos OCR preentrenados contienen capacidades latentes que pueden activarse mediante la configuración adecuada de sus hiperparámetros de inferencia. Parámetros como los umbrales de detección de texto, las opciones de preprocesamiento de imagen, y los filtros de confianza de reconocimiento pueden tener un impacto significativo en el rendimiento final. Su optimización sistemática puede aproximarse a los beneficios del fine-tuning sin sus costes asociados.
|
La hipótesis central de este trabajo es que los modelos OCR preentrenados contienen capacidades latentes que pueden activarse mediante la configuración adecuada de sus hiperparámetros de inferencia. Parámetros como los umbrales de detección de texto, las opciones de preprocesamiento de imagen, y los filtros de confianza de reconocimiento pueden tener un impacto significativo en el rendimiento final. Su optimización sistemática puede aproximarse a los beneficios del fine-tuning sin sus costes asociados.
|
||||||
|
|
||||||
Esta oportunidad se ve reforzada por la disponibilidad de frameworks modernos de optimización de hiperparámetros como Ray Tune (Liaw et al., 2018) y algoritmos de búsqueda eficientes como Optuna (Akiba et al., 2019), que permiten explorar espacios de configuración de manera sistemática y eficiente.
|
Esta oportunidad se ve reforzada por la disponibilidad de frameworks modernos de optimización de hiperparámetros como Ray Tune y algoritmos de búsqueda eficientes como Optuna, que permiten explorar espacios de configuración de manera sistemática y eficiente.
|
||||||
|
|
||||||
## Planteamiento del trabajo
|
## Planteamiento del trabajo
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Contexto y estado del arte
|
# Contexto y estado del arte
|
||||||
|
|
||||||
Para comprender el alcance y las decisiones tomadas en este trabajo, es necesario situarlo en su contexto tecnológico. El Reconocimiento Óptico de Caracteres ha recorrido un largo camino desde los primeros sistemas de plantillas de los años 50 hasta las sofisticadas arquitecturas de aprendizaje profundo actuales. A lo largo de este capítulo se revisan los fundamentos técnicos del OCR moderno. Se analizan las principales soluciones de código abierto y se identifican los vacíos en la literatura que motivan la contribución de este trabajo.
|
El Reconocimiento Óptico de Caracteres ha recorrido un largo camino desde los primeros sistemas de plantillas de los años 50 hasta las sofisticadas arquitecturas de aprendizaje profundo actuales. Motores clásicos como Tesseract marcaron un punto de inflexión en la adopción práctica de OCR en entornos reales (Smith, 2007).
|
||||||
|
|
||||||
## Contexto del problema
|
## Contexto del problema
|
||||||
|
|
||||||
@@ -137,6 +137,8 @@ Una vez detectadas las regiones de texto, la etapa de reconocimiento transcribe
|
|||||||
|
|
||||||
**CRNN (Convolutional Recurrent Neural Network)**: Propuesta por Shi et al. (2016), CRNN combina una CNN para extracción de características visuales con una RNN bidireccional (típicamente LSTM) para modelado de secuencias, entrenada con pérdida CTC. Esta arquitectura estableció el paradigma encoder-decoder que domina el campo.
|
**CRNN (Convolutional Recurrent Neural Network)**: Propuesta por Shi et al. (2016), CRNN combina una CNN para extracción de características visuales con una RNN bidireccional (típicamente LSTM) para modelado de secuencias, entrenada con pérdida CTC. Esta arquitectura estableció el paradigma encoder-decoder que domina el campo.
|
||||||
|
|
||||||
|
En reconocimiento de texto en escenas, los modelos basados en secuencias convolucionales han mostrado mejoras relevantes en precisión y velocidad (He et al., 2016).
|
||||||
|
|
||||||
La arquitectura CRNN consta de tres componentes:
|
La arquitectura CRNN consta de tres componentes:
|
||||||
1. **Capas convolucionales**: Extraen características visuales de la imagen de entrada
|
1. **Capas convolucionales**: Extraen características visuales de la imagen de entrada
|
||||||
2. **Capas recurrentes**: Modelan las dependencias secuenciales entre características
|
2. **Capas recurrentes**: Modelan las dependencias secuenciales entre características
|
||||||
@@ -201,6 +203,8 @@ El WER es generalmente mayor que el CER, ya que un solo error de carácter puede
|
|||||||
|
|
||||||
**BLEU Score**: Adaptado de traducción automática, mide la similitud entre el texto predicho y la referencia considerando n-gramas.
|
**BLEU Score**: Adaptado de traducción automática, mide la similitud entre el texto predicho y la referencia considerando n-gramas.
|
||||||
|
|
||||||
|
**Métricas derivadas de WER**: Variantes como MER y WIL complementan la evaluación de reconocimiento de secuencias (Morris et al., 2004).
|
||||||
|
|
||||||
### Particularidades del OCR para el Idioma Español
|
### Particularidades del OCR para el Idioma Español
|
||||||
|
|
||||||
El español, como lengua romance, presenta características específicas que impactan el rendimiento de los sistemas OCR:
|
El español, como lengua romance, presenta características específicas que impactan el rendimiento de los sistemas OCR:
|
||||||
@@ -240,7 +244,7 @@ En los últimos años han surgido varias soluciones OCR de código abierto que d
|
|||||||
|
|
||||||
#### EasyOCR
|
#### EasyOCR
|
||||||
|
|
||||||
EasyOCR es una biblioteca de OCR desarrollada por Jaided AI (2020) con el objetivo de proporcionar una solución de fácil uso que soporte múltiples idiomas. Actualmente soporta más de 80 idiomas, incluyendo español.
|
EasyOCR es una librería de OCR desarrollada por JaidedAI (2020) con el objetivo de proporcionar una solución de fácil uso que soporte múltiples idiomas. Actualmente soporta más de 80 idiomas, incluyendo español.
|
||||||
|
|
||||||
**Arquitectura técnica**:
|
**Arquitectura técnica**:
|
||||||
- **Detector**: CRAFT (Character Region Awareness for Text Detection)
|
- **Detector**: CRAFT (Character Region Awareness for Text Detection)
|
||||||
@@ -263,7 +267,7 @@ EasyOCR es una biblioteca de OCR desarrollada por Jaided AI (2020) con el objeti
|
|||||||
|
|
||||||
#### PaddleOCR
|
#### PaddleOCR
|
||||||
|
|
||||||
PaddleOCR es el sistema OCR desarrollado por Baidu como parte del ecosistema PaddlePaddle (2024). Representa una de las soluciones más completas y activamente mantenidas en el ecosistema de código abierto. La versión PP-OCRv5, utilizada en este trabajo, incorpora los últimos avances en el campo.
|
PaddleOCR es el sistema OCR desarrollado por Baidu como parte del ecosistema PaddlePaddle (2024). Representa una de las soluciones más completas y activamente mantenidas en el ecosistema de código abierto. Su evolución incluye PP-OCR (Du et al., 2020) y PP-OCRv4 (Du et al., 2023); la versión PP-OCRv5, utilizada en este trabajo, incorpora avances recientes en precisión y eficiencia.
|
||||||
|
|
||||||
**Arquitectura técnica**:
|
**Arquitectura técnica**:
|
||||||
|
|
||||||
@@ -424,7 +428,7 @@ Desventajas:
|
|||||||
|
|
||||||
Propuesto por Bergstra & Bengio (2012), Random Search muestrea configuraciones aleatoriamente del espacio de búsqueda. Sorprendentemente, supera a Grid Search en muchos escenarios prácticos.
|
Propuesto por Bergstra & Bengio (2012), Random Search muestrea configuraciones aleatoriamente del espacio de búsqueda. Sorprendentemente, supera a Grid Search en muchos escenarios prácticos.
|
||||||
|
|
||||||
La intuición es que, cuando solo algunos hiperparámetros son importantes, Random Search explora más valores de estos parámetros críticos mientras Grid Search desperdicia evaluaciones variando parámetros irrelevantes.
|
La intuición es que, cuando solo algunos hiperparámetros son importantes, Random Search explora más valores de estos parámetros críticos mientras Grid Search desperdicia evaluaciones variando parámetros irrelevantes. En muchos escenarios, la búsqueda aleatoria ofrece un baseline competitivo (Bergstra & Bengio, 2012).
|
||||||
|
|
||||||
**Optimización Bayesiana**:
|
**Optimización Bayesiana**:
|
||||||
|
|
||||||
@@ -463,7 +467,7 @@ Configuraciones con alta probabilidad bajo $l$ y baja probabilidad bajo $g$ tien
|
|||||||
|
|
||||||
#### Ray Tune
|
#### Ray Tune
|
||||||
|
|
||||||
Ray Tune (Liaw et al., 2018) es un framework de optimización de hiperparámetros escalable construido sobre Ray, un sistema de computación distribuida. Sus características principales incluyen:
|
Ray Tune (Liaw et al., 2018) es un framework de optimización de hiperparámetros escalable construido sobre Ray, un sistema de computación distribuida (Moritz et al., 2018). Sus características principales incluyen:
|
||||||
|
|
||||||
**Escalabilidad**:
|
**Escalabilidad**:
|
||||||
- Ejecución paralela de múltiples trials
|
- Ejecución paralela de múltiples trials
|
||||||
@@ -487,6 +491,8 @@ La combinación de Ray Tune con OptunaSearch permite:
|
|||||||
3. Beneficiarse de la infraestructura de Ray para distribución
|
3. Beneficiarse de la infraestructura de Ray para distribución
|
||||||
4. Acceder a las visualizaciones de Optuna
|
4. Acceder a las visualizaciones de Optuna
|
||||||
|
|
||||||
|
Optuna se ha consolidado como una opción práctica y eficiente para optimización de hiperparámetros en problemas reales (Akiba et al., 2019).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
---
|
---
|
||||||
title: "Ciclo de optimización con Ray Tune y Optuna"
|
title: "Ciclo de optimización con Ray Tune y Optuna"
|
||||||
@@ -523,6 +529,10 @@ Breuel (2013) exploró la selección automática de arquitecturas de red para re
|
|||||||
|
|
||||||
Schulz & Kuhn (2017) optimizaron parámetros de modelos de lenguaje para corrección de errores OCR, incluyendo pesos de interpolación entre modelos de caracteres y palabras.
|
Schulz & Kuhn (2017) optimizaron parámetros de modelos de lenguaje para corrección de errores OCR, incluyendo pesos de interpolación entre modelos de caracteres y palabras.
|
||||||
|
|
||||||
|
Además, la variabilidad del rendimiento puede analizarse mediante correlaciones lineales (Pearson, 1895), complementadas por criterios de magnitud del efecto (Cohen, 1988).
|
||||||
|
|
||||||
|
Finalmente, líneas de AutoML como la búsqueda de arquitecturas (NAS) representan alternativas más costosas pero potencialmente automatizables para optimizar modelos (Zoph & Le, 2017).
|
||||||
|
|
||||||
**Vacío en la literatura**:
|
**Vacío en la literatura**:
|
||||||
|
|
||||||
A pesar de estos trabajos, existe un vacío significativo respecto a la optimización sistemática de hiperparámetros de inferencia en pipelines OCR modernos como PaddleOCR. La mayoría de trabajos se centran en:
|
A pesar de estos trabajos, existe un vacío significativo respecto a la optimización sistemática de hiperparámetros de inferencia en pipelines OCR modernos como PaddleOCR. La mayoría de trabajos se centran en:
|
||||||
@@ -588,7 +598,9 @@ Los trabajos previos en OCR para español se han centrado principalmente en:
|
|||||||
|
|
||||||
La optimización de hiperparámetros para documentos académicos en español representa una contribución original de este trabajo, abordando un nicho no explorado en la literatura.
|
La optimización de hiperparámetros para documentos académicos en español representa una contribución original de este trabajo, abordando un nicho no explorado en la literatura.
|
||||||
|
|
||||||
En síntesis, la revisión del estado del arte revela un panorama en el que las herramientas técnicas están maduras, pero su aplicación óptima para dominios específicos permanece poco explorada. Los sistemas OCR modernos, como PaddleOCR, EasyOCR y DocTR, ofrecen arquitecturas sofisticadas basadas en aprendizaje profundo que alcanzan resultados impresionantes en benchmarks estándar. Sin embargo, estos resultados no siempre se trasladan a documentos del mundo real, especialmente en idiomas con menos recursos como el español.
|
## Conclusiones
|
||||||
|
|
||||||
|
La revisión del estado del arte revela un panorama en el que las herramientas técnicas están maduras, pero su aplicación óptima para dominios específicos permanece poco explorada. Los sistemas OCR modernos, como PaddleOCR, EasyOCR y DocTR, ofrecen arquitecturas sofisticadas basadas en aprendizaje profundo que alcanzan resultados impresionantes en benchmarks estándar. Sin embargo, estos resultados no siempre se trasladan a documentos del mundo real, especialmente en idiomas con menos recursos como el español.
|
||||||
|
|
||||||
La evolución desde los sistemas de plantillas de los años 50 hasta los Transformers actuales ha sido espectacular, pero ha generado sistemas con decenas de hiperparámetros configurables cuyos valores por defecto representan compromisos generales, no configuraciones óptimas para dominios específicos. La literatura abunda en trabajos sobre entrenamiento y fine-tuning de modelos OCR, pero dedica poca atención a la optimización sistemática de los parámetros de inferencia, como umbrales de detección, opciones de preprocesamiento y filtros de confianza, que pueden marcar la diferencia entre un sistema usable y uno que requiere corrección manual extensiva.
|
La evolución desde los sistemas de plantillas de los años 50 hasta los Transformers actuales ha sido espectacular, pero ha generado sistemas con decenas de hiperparámetros configurables cuyos valores por defecto representan compromisos generales, no configuraciones óptimas para dominios específicos. La literatura abunda en trabajos sobre entrenamiento y fine-tuning de modelos OCR, pero dedica poca atención a la optimización sistemática de los parámetros de inferencia, como umbrales de detección, opciones de preprocesamiento y filtros de confianza, que pueden marcar la diferencia entre un sistema usable y uno que requiere corrección manual extensiva.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
# Objetivos concretos y metodología de trabajo
|
# Objetivos concretos y metodología de trabajo
|
||||||
|
|
||||||
La motivación presentada en el capítulo anterior se traduce ahora en objetivos concretos y medibles. Siguiendo la metodología SMART propuesta por Doran (1981), se define un objetivo general que guía el trabajo y cinco objetivos específicos que lo descomponen en metas alcanzables. La segunda parte del capítulo describe la metodología experimental diseñada para alcanzar estos objetivos.
|
|
||||||
|
|
||||||
## Objetivo general
|
## Objetivo general
|
||||||
|
|
||||||
> **Optimizar el rendimiento de PaddleOCR para documentos académicos en español mediante ajuste de hiperparámetros, alcanzando un CER inferior al 2% sin requerir fine-tuning del modelo.**
|
> **Optimizar el rendimiento de PaddleOCR para documentos académicos en español mediante ajuste de hiperparámetros, alcanzando un CER inferior al 2% sin requerir fine-tuning del modelo.**
|
||||||
|
|
||||||
### Justificación SMART del Objetivo General
|
### Justificación SMART del Objetivo General (Doran, 1981)
|
||||||
|
|
||||||
**Tabla 13.** *Justificación SMART del objetivo general.*
|
**Tabla 13.** *Justificación SMART del objetivo general.*
|
||||||
|
|
||||||
@@ -60,7 +58,7 @@ flowchart LR
|
|||||||
|
|
||||||
**Descripción de las fases:**
|
**Descripción de las fases:**
|
||||||
|
|
||||||
- **Fase 1 - Preparación del Dataset**: Conversión PDF a imágenes (300 DPI), extracción de ground truth con PyMuPDF
|
- **Fase 1 - Preparación del Dataset**: Conversión PDF a imágenes (300 DPI), extracción de ground truth con PyMuPDF (PyMuPDF, 2024)
|
||||||
- **Fase 2 - Benchmark Comparativo**: Evaluación de EasyOCR, PaddleOCR, DocTR con métricas CER/WER
|
- **Fase 2 - Benchmark Comparativo**: Evaluación de EasyOCR, PaddleOCR, DocTR con métricas CER/WER
|
||||||
- **Fase 3 - Espacio de Búsqueda**: Identificación de hiperparámetros y configuración de Ray Tune + Optuna
|
- **Fase 3 - Espacio de Búsqueda**: Identificación de hiperparámetros y configuración de Ray Tune + Optuna
|
||||||
- **Fase 4 - Optimización**: Ejecución de 64 trials con paralelización (2 concurrentes)
|
- **Fase 4 - Optimización**: Ejecución de 64 trials con paralelización (2 concurrentes)
|
||||||
@@ -167,7 +165,7 @@ Se utilizó la biblioteca `jiwer` para calcular CER y WER comparando el texto de
|
|||||||
|
|
||||||
#### Configuración de Ray Tune
|
#### Configuración de Ray Tune
|
||||||
|
|
||||||
El espacio de búsqueda se definió utilizando `tune.choice()` para parámetros booleanos y `tune.uniform()` para parámetros continuos, con OptunaSearch como algoritmo de optimización configurado para minimizar CER en 64 trials. La implementación completa está disponible en [`src/raytune/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune/raytune_ocr.py) (ver Anexo A).
|
El espacio de búsqueda se definió utilizando `tune.choice()` para parámetros booleanos y `tune.uniform()` para parámetros continuos, con OptunaSearch como algoritmo de optimización configurado para minimizar CER en 64 trials. La implementación completa está disponible en [`src/raytune/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune/raytune_ocr.py) (ver Anexo A). Ray Tune se apoya en el ecosistema Ray para escalar la búsqueda (Moritz et al., 2018).
|
||||||
|
|
||||||
### Fase 4: Ejecución de Optimización
|
### Fase 4: Ejecución de Optimización
|
||||||
|
|
||||||
@@ -286,7 +284,7 @@ Para un proyecto de investigación con múltiples iteraciones de ajuste de hiper
|
|||||||
|
|
||||||
1. **Tamaño del dataset**: El dataset contiene 45 páginas de documentos académicos UNIR. Resultados pueden no generalizar a otros formatos.
|
1. **Tamaño del dataset**: El dataset contiene 45 páginas de documentos académicos UNIR. Resultados pueden no generalizar a otros formatos.
|
||||||
|
|
||||||
2. **Subconjunto de optimización**: El ajuste de hiperparámetros se realizó sobre 5 páginas (páginas 5-10), lo que contribuyó al sobreajuste observado en la validación del dataset completo.
|
2. **Subconjunto de optimización**: El ajuste de hiperparámetros se realizó sobre 5 páginas (páginas 5-10), y su impacto se analiza en detalle en el capítulo de desarrollo específico.
|
||||||
|
|
||||||
3. **Texto de referencia imperfecto**: El texto de referencia extraído de PDF puede contener errores en documentos con diseños complejos.
|
3. **Texto de referencia imperfecto**: El texto de referencia extraído de PDF puede contener errores en documentos con diseños complejos.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
# Desarrollo específico de la contribución
|
# Desarrollo específico de la contribución
|
||||||
|
|
||||||
El presente capítulo constituye el núcleo técnico de este trabajo fin de máster. Siguiendo la estructura de "Comparativa de soluciones" establecida por las instrucciones de UNIR, se desarrollan tres fases interrelacionadas. Estas fases son tres: planteamiento y ejecución del benchmark comparativo, optimización de hiperparámetros mediante Ray Tune, y análisis e interpretación de los resultados.
|
|
||||||
|
|
||||||
## Planteamiento de la comparativa
|
## Planteamiento de la comparativa
|
||||||
|
|
||||||
### Introducción
|
|
||||||
|
|
||||||
Antes de abordar la optimización de hiperparámetros, era necesario seleccionar el motor OCR que serviría como base para la experimentación. Para ello, se realizó un estudio comparativo entre tres soluciones de código abierto representativas del estado del arte: EasyOCR, PaddleOCR y DocTR. Los experimentos, documentados en los informes de métricas y en los CSV de resultados del repositorio, permitieron identificar el modelo más prometedor para la fase de optimización posterior.
|
Antes de abordar la optimización de hiperparámetros, era necesario seleccionar el motor OCR que serviría como base para la experimentación. Para ello, se realizó un estudio comparativo entre tres soluciones de código abierto representativas del estado del arte: EasyOCR, PaddleOCR y DocTR. Los experimentos, documentados en los informes de métricas y en los CSV de resultados del repositorio, permitieron identificar el modelo más prometedor para la fase de optimización posterior.
|
||||||
|
|
||||||
### Identificación del Problema
|
### Identificación del Problema
|
||||||
@@ -18,7 +14,7 @@ Los documentos académicos típicos incluyen texto corrido con índice, listas n
|
|||||||
|
|
||||||
Se seleccionaron tres soluciones OCR de código abierto representativas del estado del arte:
|
Se seleccionaron tres soluciones OCR de código abierto representativas del estado del arte:
|
||||||
|
|
||||||
**Tabla 20.** *Soluciones OCR evaluadas en el benchmark comparativo.*
|
**Tabla 20.** *Soluciones OCR del benchmark.*
|
||||||
|
|
||||||
| Solución | Desarrollador | Versión | Justificación de selección |
|
| Solución | Desarrollador | Versión | Justificación de selección |
|
||||||
|----------|---------------|---------|----------------------------|
|
|----------|---------------|---------|----------------------------|
|
||||||
@@ -205,20 +201,16 @@ Esta riqueza de configuración permite explorar sistemáticamente el espacio de
|
|||||||
|
|
||||||
2. **Único tipo de documento**: Documentos académicos de UNIR únicamente. Otros tipos de documentos (facturas, formularios, contratos) podrían presentar resultados diferentes.
|
2. **Único tipo de documento**: Documentos académicos de UNIR únicamente. Otros tipos de documentos (facturas, formularios, contratos) podrían presentar resultados diferentes.
|
||||||
|
|
||||||
3. **Ground truth automático**: El texto de referencia se extrajo programáticamente del PDF, lo cual puede introducir errores en el orden de lectura cuando hay secciones con encabezados y saltos de línea.
|
3. **Referencia CPU separada**: Los tiempos en CPU se midieron en un experimento independiente y solo se usan como comparación de rendimiento frente a GPU.
|
||||||
|
|
||||||
4. **Referencia CPU separada**: Los tiempos en CPU se midieron en un experimento independiente y solo se usan como comparación de rendimiento frente a GPU.
|
|
||||||
|
|
||||||
### Síntesis del Benchmark
|
### Síntesis del Benchmark
|
||||||
|
|
||||||
El benchmark comparativo ha permitido identificar PaddleOCR como la solución más prometedora para la fase de optimización, gracias a su combinación de rendimiento base competitivo, alta configurabilidad del pipeline y documentación técnica completa. Sin embargo, el análisis también reveló limitaciones importantes: el tamaño reducido del benchmark (5 páginas), la restricción a un único tipo de documento, y la extracción automática del ground truth que puede introducir errores en el orden de lectura cuando hay secciones con encabezados y saltos de línea. Estas limitaciones se tendrán en cuenta al interpretar los resultados de la fase de optimización.
|
El benchmark comparativo ha permitido identificar PaddleOCR como la solución más prometedora para la fase de optimización, gracias a su combinación de rendimiento base competitivo, alta configurabilidad del pipeline y documentación técnica completa. Estas limitaciones se tendrán en cuenta al interpretar los resultados de la fase de optimización.
|
||||||
|
|
||||||
**Fuente:** [`docs/metrics/metrics.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics.md), [`src/results/*.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/*.csv), documentación oficial de PaddleOCR.
|
**Fuente:** [`docs/metrics/metrics.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics.md), [`src/results/*.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/*.csv), documentación oficial de PaddleOCR.
|
||||||
|
|
||||||
## Desarrollo de la comparativa: Optimización de hiperparámetros
|
## Desarrollo de la comparativa: Optimización de hiperparámetros
|
||||||
|
|
||||||
### Introducción
|
|
||||||
|
|
||||||
Una vez seleccionado PaddleOCR como motor base, el siguiente paso fue explorar sistemáticamente su espacio de configuración para identificar los hiperparámetros que maximizan el rendimiento en documentos académicos en español. Para ello se empleó Ray Tune con el algoritmo de búsqueda Optuna, una combinación que permite explorar eficientemente espacios de búsqueda mixtos (parámetros continuos y categóricos). Los experimentos se implementaron en [`src/run_tuning.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/run_tuning.py) con apoyo de la librería [`src/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_ocr.py), almacenándose los resultados en [`src/results`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results). Esta aproximación ofrece ventajas significativas frente al fine-tuning tradicional: no requiere datasets de entrenamiento etiquetados, no modifica los pesos del modelo preentrenado, y puede ejecutarse con hardware de consumo cuando se dispone de aceleración GPU.
|
Una vez seleccionado PaddleOCR como motor base, el siguiente paso fue explorar sistemáticamente su espacio de configuración para identificar los hiperparámetros que maximizan el rendimiento en documentos académicos en español. Para ello se empleó Ray Tune con el algoritmo de búsqueda Optuna, una combinación que permite explorar eficientemente espacios de búsqueda mixtos (parámetros continuos y categóricos). Los experimentos se implementaron en [`src/run_tuning.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/run_tuning.py) con apoyo de la librería [`src/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_ocr.py), almacenándose los resultados en [`src/results`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results). Esta aproximación ofrece ventajas significativas frente al fine-tuning tradicional: no requiere datasets de entrenamiento etiquetados, no modifica los pesos del modelo preentrenado, y puede ejecutarse con hardware de consumo cuando se dispone de aceleración GPU.
|
||||||
|
|
||||||
### Configuración del Experimento
|
### Configuración del Experimento
|
||||||
@@ -538,7 +530,7 @@ La clase `ImageTextDataset` gestiona la carga de pares imagen-texto desde la est
|
|||||||
|
|
||||||
El espacio de búsqueda se definió considerando los hiperparámetros más relevantes identificados en la documentación de PaddleOCR, utilizando `tune.choice()` para parámetros booleanos y `tune.uniform()` para umbrales continuos. La implementación está disponible en [`src/raytune/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune/raytune_ocr.py) (ver Anexo A).
|
El espacio de búsqueda se definió considerando los hiperparámetros más relevantes identificados en la documentación de PaddleOCR, utilizando `tune.choice()` para parámetros booleanos y `tune.uniform()` para umbrales continuos. La implementación está disponible en [`src/raytune/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune/raytune_ocr.py) (ver Anexo A).
|
||||||
|
|
||||||
**Tabla 30.** *Descripción detallada del espacio de búsqueda.*
|
**Tabla 30.** *Espacio de búsqueda: parámetros.*
|
||||||
|
|
||||||
| Parámetro | Tipo | Rango | Descripción |
|
| Parámetro | Tipo | Rango | Descripción |
|
||||||
|-----------|------|-------|-------------|
|
|-----------|------|-------|-------------|
|
||||||
@@ -694,7 +686,7 @@ Configuración óptima:
|
|||||||
|
|
||||||
#### Análisis de Correlación
|
#### Análisis de Correlación
|
||||||
|
|
||||||
Se calculó la correlación de Pearson entre los parámetros de configuración (codificados como 0/1 en el caso de booleanos) y las métricas de error:
|
Se calculó la correlación de Pearson entre los parámetros de configuración (codificados como 0/1 en el caso de booleanos) y las métricas de error. Para interpretar la magnitud de las correlaciones se siguieron criterios habituales en investigación cuantitativa:
|
||||||
|
|
||||||
**Tabla 36.** *Correlación de parámetros con CER.*
|
**Tabla 36.** *Correlación de parámetros con CER.*
|
||||||
|
|
||||||
@@ -831,7 +823,7 @@ La configuración óptima identificada se evaluó sobre el dataset completo de 4
|
|||||||
|
|
||||||
**Fuente:** [`docs/metrics/metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md).
|
**Fuente:** [`docs/metrics/metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md).
|
||||||
|
|
||||||
> **Nota sobre generalización:** El mejor trial individual (5 páginas) alcanzó un CER de 0.79%, cumpliendo el objetivo de CER < 2%. Sin embargo, al aplicar la configuración al dataset completo de 45 páginas, el CER aumentó a 7.72%, evidenciando sobreajuste al subconjunto de entrenamiento. Esta diferencia es un hallazgo importante que se discute en la sección de análisis.
|
> **Nota sobre generalización:** El contraste entre el mejor trial y la validación en el dataset completo evidencia sobreajuste al subconjunto de entrenamiento. Esta diferencia se analiza en la sección de resultados consolidados.
|
||||||
|
|
||||||
#### Métricas de Mejora
|
#### Métricas de Mejora
|
||||||
|
|
||||||
@@ -848,11 +840,11 @@ La configuración óptima identificada se evaluó sobre el dataset completo de 4
|
|||||||
|
|
||||||
**Fuente:** [`docs/metrics/metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md).
|
**Fuente:** [`docs/metrics/metrics_paddle.md`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/docs/metrics/metrics_paddle.md).
|
||||||
|
|
||||||
**Figura 18.** *Reducción de errores: baseline vs optimizado (45 páginas).*
|
**Figura 18.** *Reducción de errores (baseline vs optimizado).*
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
---
|
---
|
||||||
title: "Reducción de errores: Baseline vs Optimizado (45 páginas)"
|
title: "Reducción de errores (baseline vs optimizado)"
|
||||||
config:
|
config:
|
||||||
theme: base
|
theme: base
|
||||||
themeVariables:
|
themeVariables:
|
||||||
@@ -901,14 +893,12 @@ Observaciones:
|
|||||||
|
|
||||||
Los 64 trials ejecutados con Ray Tune y aceleración GPU revelaron patrones claros en el comportamiento de PaddleOCR. El hallazgo más significativo es que los parámetros estructurales, `textline_orientation` y `use_doc_orientation_classify`, tienen mayor impacto que los umbrales numéricos. Al activarlos se reduce el CER medio de 4.73% a 1.74%. En cuanto a umbrales, valores bajos de `text_det_thresh` (aprox. 0.05) benefician el rendimiento, mientras que `use_doc_unwarping` resulta innecesario para PDFs digitales.
|
Los 64 trials ejecutados con Ray Tune y aceleración GPU revelaron patrones claros en el comportamiento de PaddleOCR. El hallazgo más significativo es que los parámetros estructurales, `textline_orientation` y `use_doc_orientation_classify`, tienen mayor impacto que los umbrales numéricos. Al activarlos se reduce el CER medio de 4.73% a 1.74%. En cuanto a umbrales, valores bajos de `text_det_thresh` (aprox. 0.05) benefician el rendimiento, mientras que `use_doc_unwarping` resulta innecesario para PDFs digitales.
|
||||||
|
|
||||||
El mejor trial alcanzó un CER de 0.79%, cumpliendo el objetivo de CER < 2%. No obstante, la validación sobre el dataset completo de 45 páginas arrojó un CER de 7.72%, evidenciando sobreajuste al subconjunto de optimización de 5 páginas. Aun así, esto representa una mejora del 12.8% respecto al baseline (8.85%), demostrando el valor de la optimización sistemática incluso cuando la generalización es imperfecta.
|
La optimización logró mejoras claras frente al baseline en el dataset completo, aunque la generalización quedó limitada por el tamaño del subconjunto de ajuste.
|
||||||
|
|
||||||
**Fuente:** [`src/run_tuning.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/run_tuning.py), [`src/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_ocr.py), [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv).
|
**Fuente:** [`src/run_tuning.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/run_tuning.py), [`src/raytune_ocr.py`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_ocr.py), [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv).
|
||||||
|
|
||||||
## Discusión y análisis de resultados
|
## Discusión y análisis de resultados
|
||||||
|
|
||||||
### Introducción
|
|
||||||
|
|
||||||
Los resultados obtenidos en las secciones anteriores requieren un análisis que trascienda los números individuales para comprender su significado práctico. En esta sección se consolidan los hallazgos del benchmark comparativo y la optimización de hiperparámetros, evaluando hasta qué punto se han cumplido los objetivos planteados y qué limitaciones condicionan la generalización de las conclusiones.
|
Los resultados obtenidos en las secciones anteriores requieren un análisis que trascienda los números individuales para comprender su significado práctico. En esta sección se consolidan los hallazgos del benchmark comparativo y la optimización de hiperparámetros, evaluando hasta qué punto se han cumplido los objetivos planteados y qué limitaciones condicionan la generalización de las conclusiones.
|
||||||
|
|
||||||
### Resumen Consolidado de Resultados
|
### Resumen Consolidado de Resultados
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
# Conclusiones y trabajo futuro
|
# Conclusiones y trabajo futuro
|
||||||
|
|
||||||
A lo largo de este trabajo se ha explorado la optimización de hiperparámetros como estrategia para mejorar el rendimiento de sistemas OCR sin necesidad de reentrenamiento. Las siguientes secciones evalúan el grado de cumplimiento de los objetivos planteados, sintetizan los hallazgos más relevantes y proponen direcciones para investigación futura.
|
|
||||||
|
|
||||||
## Conclusiones
|
## Conclusiones
|
||||||
|
|
||||||
### Conclusiones Generales
|
|
||||||
|
|
||||||
Los resultados obtenidos confirman que la optimización sistemática de hiperparámetros constituye una alternativa viable al fine-tuning para mejorar sistemas OCR preentrenados. La infraestructura dockerizada con aceleración GPU desarrollada en este trabajo no solo facilita la experimentación reproducible, sino que reduce drásticamente los tiempos de ejecución, haciendo viable la exploración exhaustiva de espacios de configuración.
|
Los resultados obtenidos confirman que la optimización sistemática de hiperparámetros constituye una alternativa viable al fine-tuning para mejorar sistemas OCR preentrenados. La infraestructura dockerizada con aceleración GPU desarrollada en este trabajo no solo facilita la experimentación reproducible, sino que reduce drásticamente los tiempos de ejecución, haciendo viable la exploración exhaustiva de espacios de configuración.
|
||||||
|
|
||||||
El objetivo principal del trabajo era alcanzar un CER inferior al 2% en documentos académicos en español. Los resultados obtenidos se resumen a continuación:
|
El objetivo principal del trabajo era alcanzar un CER inferior al 2% en documentos académicos en español. Los resultados obtenidos se resumen a continuación:
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ Esta sección presenta los resultados completos de las evaluaciones comparativas
|
|||||||
|
|
||||||
### Comparativa General de Servicios
|
### Comparativa General de Servicios
|
||||||
|
|
||||||
**Tabla A4.** *Comparativa de servicios OCR en dataset de 45 páginas (GPU RTX 3060).*
|
**Tabla A4.** *Servicios OCR en 45 páginas (RTX 3060).*
|
||||||
|
|
||||||
| Servicio | CER | WER | Tiempo/Página | Tiempo Total | VRAM |
|
| Servicio | CER | WER | Tiempo/Página | Tiempo Total | VRAM |
|
||||||
|----------|-----|-----|---------------|--------------|------|
|
|----------|-----|-----|---------------|--------------|------|
|
||||||
@@ -247,27 +247,7 @@ Se ejecutaron 64 trials por servicio utilizando Ray Tune con Optuna sobre las p
|
|||||||
|
|
||||||
**Fuente:** [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv).
|
**Fuente:** [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv).
|
||||||
|
|
||||||
**Figura A1.** *Distribución de trials por rango de CER (PaddleOCR).*
|
> **Nota:** Ver [Figura 15](#figura-15) en el Capítulo 4 para la representación gráfica de esta distribución.
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
title: "Distribución de trials por rango de CER (PaddleOCR)"
|
|
||||||
config:
|
|
||||||
theme: base
|
|
||||||
themeVariables:
|
|
||||||
primaryColor: "#E6F4F9"
|
|
||||||
primaryTextColor: "#404040"
|
|
||||||
primaryBorderColor: "#0098CD"
|
|
||||||
lineColor: "#0098CD"
|
|
||||||
---
|
|
||||||
pie showData
|
|
||||||
title Distribución de 64 trials
|
|
||||||
"CER < 2%" : 43
|
|
||||||
"CER 2-5%" : 10
|
|
||||||
"CER 5-10%" : 11
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fuente:** [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv).
|
|
||||||
|
|
||||||
### Configuración Óptima PaddleOCR
|
### Configuración Óptima PaddleOCR
|
||||||
|
|
||||||
@@ -302,28 +282,7 @@ La siguiente configuración logró el mejor rendimiento en el ajuste de hiperpar
|
|||||||
|
|
||||||
**Fuente:** Datos de tiempo CPU de [`src/raytune_paddle_subproc_results_20251207_192320.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_paddle_subproc_results_20251207_192320.csv) y tiempos de GPU en trials de ajuste. Elaboración propia.
|
**Fuente:** Datos de tiempo CPU de [`src/raytune_paddle_subproc_results_20251207_192320.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_paddle_subproc_results_20251207_192320.csv) y tiempos de GPU en trials de ajuste. Elaboración propia.
|
||||||
|
|
||||||
**Figura A2.** *Tiempo de procesamiento: CPU vs GPU (segundos/página).*
|
> **Nota:** Ver [Figura 20](#figura-20) en el Capítulo 4 para la representación gráfica de esta comparación.
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
title: "Tiempo de procesamiento: CPU vs GPU (segundos/página)"
|
|
||||||
config:
|
|
||||||
theme: base
|
|
||||||
themeVariables:
|
|
||||||
primaryColor: "#E6F4F9"
|
|
||||||
primaryTextColor: "#404040"
|
|
||||||
primaryBorderColor: "#0098CD"
|
|
||||||
lineColor: "#0098CD"
|
|
||||||
xyChart:
|
|
||||||
plotColorPalette: "#0098CD"
|
|
||||||
---
|
|
||||||
xychart-beta
|
|
||||||
x-axis ["CPU", "GPU (RTX 3060)"]
|
|
||||||
y-axis "Segundos por página" 0 --> 75
|
|
||||||
bar [69.4, 0.84]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fuente:** [`src/raytune_paddle_subproc_results_20251207_192320.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/raytune_paddle_subproc_results_20251207_192320.csv) y [`src/results/raytune_paddle_results_20260119_122609.csv`](https://seryus.ddns.net/unir/MastersThesis/src/branch/main/src/results/raytune_paddle_results_20260119_122609.csv). Leyenda: Aceleración de **82x** con GPU. El procesamiento de una página pasa de 69.4s (CPU) a 0.84s (GPU).
|
|
||||||
|
|
||||||
### Análisis de Errores por Servicio
|
### Análisis de Errores por Servicio
|
||||||
|
|
||||||
@@ -375,3 +334,18 @@ Requisitos extraídos de la documentación oficial de las dependencias usadas:
|
|||||||
## A.10 Licencia
|
## A.10 Licencia
|
||||||
|
|
||||||
El código se distribuye bajo licencia MIT.
|
El código se distribuye bajo licencia MIT.
|
||||||
|
|
||||||
|
## A.11 Índice de acrónimos
|
||||||
|
|
||||||
|
**Tabla A10.** *Acrónimos utilizados en el trabajo.*
|
||||||
|
|
||||||
|
| Acrónimo | Significado |
|
||||||
|
|----------|-------------|
|
||||||
|
| OCR | Reconocimiento Óptico de Caracteres |
|
||||||
|
| CER | Character Error Rate (tasa de error de caracteres) |
|
||||||
|
| WER | Word Error Rate (tasa de error de palabras) |
|
||||||
|
| GPU | Graphics Processing Unit |
|
||||||
|
| CPU | Central Processing Unit |
|
||||||
|
| API | Application Programming Interface |
|
||||||
|
|
||||||
|
**Fuente:** Elaboración propia.
|
||||||
|
|||||||
@@ -1,157 +1,256 @@
|
|||||||
# UNIR Style Compliance Checklist
|
# Lista de verificación de cumplimiento de estilo UNIR
|
||||||
|
|
||||||
This document lists the UNIR TFE style requirements to verify before final submission.
|
Este documento reúne los requisitos de estilo del TFE de UNIR a verificar antes de la entrega final.
|
||||||
|
|
||||||
## Page Layout
|
## Maquetación de página
|
||||||
|
|
||||||
| Requirement | Specification | Check |
|
| Requisito | Especificación | Check |
|
||||||
|-------------|---------------|-------|
|
|-----------|----------------|-------|
|
||||||
| Page size | A4 | ☐ |
|
| Tamaño de página | A4 | ☐ |
|
||||||
| Left margin | 3.0 cm | ☐ |
|
| Margen izquierdo | 3.0 cm | ☐ |
|
||||||
| Right margin | 2.0 cm | ☐ |
|
| Margen derecho | 2.0 cm | ☐ |
|
||||||
| Top margin | 2.5 cm | ☐ |
|
| Margen superior | 2.5 cm | ☐ |
|
||||||
| Bottom margin | 2.5 cm | ☐ |
|
| Margen inferior | 2.5 cm | ☐ |
|
||||||
| Header | Student name + TFE title | ☐ |
|
| Encabezado | Nombre del estudiante + título del TFE | ☐ |
|
||||||
| Footer | Page number | ☐ |
|
| Pie de página | Número de página | ☐ |
|
||||||
|
|
||||||
## Typography
|
## Tipografía
|
||||||
|
|
||||||
| Element | Specification | Check |
|
| Elemento | Especificación | Check |
|
||||||
|---------|---------------|-------|
|
|----------|----------------|-------|
|
||||||
| Body text | Calibri 12pt, justified, 1.5 line spacing | ☐ |
|
| Texto normal | Calibri 12 pt, justificado, interlineado 1.5 | ☐ |
|
||||||
| Título 1 (H1) | Calibri Light 18pt, blue, numbered (1., 2., ...) | ☐ |
|
| Título 1 (H1) | Calibri Light 18 pt, azul, numerado (1., 2., ...) | ☐ |
|
||||||
| Título 2 (H2) | Calibri Light 14pt, blue, numbered (1.1, 1.2, ...) | ☐ |
|
| Título 2 (H2) | Calibri Light 14 pt, azul, numerado (1.1, 1.2, ...) | ☐ |
|
||||||
| Título 3 (H3) | Calibri Light 12pt, numbered (1.1.1, 1.1.2, ...) | ☐ |
|
| Título 3 (H3) | Calibri Light 12 pt, numerado (1.1.1, 1.1.2, ...) | ☐ |
|
||||||
| Título 4 (H4) | Calibri 12pt, bold, unnumbered | ☐ |
|
| Título 4 (H4) | Calibri 12 pt, negrita, sin numeración | ☐ |
|
||||||
| Footnotes | Calibri 10pt, justified, single spacing | ☐ |
|
| Notas al pie | Calibri 10 pt, justificado, interlineado simple | ☐ |
|
||||||
| Code blocks | Consolas 10pt | ☐ |
|
| Bloques de código | Consolas 10 pt | ☐ |
|
||||||
|
|
||||||
## Document Structure
|
## Estructura del documento
|
||||||
|
|
||||||
| Section | Requirements | Check |
|
| Sección | Requisitos | Check |
|
||||||
|---------|--------------|-------|
|
|---------|------------|-------|
|
||||||
| Portada | Title, Author, Type, Director, Date | ☐ |
|
| Portada | Título, autor, tipo, director, fecha | ☐ |
|
||||||
| Resumen | 150-300 words in Spanish + Palabras clave (3-5) | ☐ |
|
| Resumen | 150-300 palabras en español (plantilla) + Palabras clave (3-5); instrucciones mencionan ~150 palabras | ☐ |
|
||||||
| Abstract | 150-300 words in English + Keywords (3-5) | ☐ |
|
| Resumen (contenido) | Debe incluir objetivo, metodología, resultados y conclusiones | ☐ |
|
||||||
| Índice de contenidos | Auto-generated, new page | ☐ |
|
| Abstract | 150-300 palabras en inglés + Keywords (3-5) | ☐ |
|
||||||
| Índice de figuras | Auto-generated, new page | ☐ |
|
| Abstract (contenido) | Debe incluir objetivo, metodología, resultados y conclusiones | ☐ |
|
||||||
| Índice de tablas | Auto-generated, new page | ☐ |
|
| Ubicación Resumen/Abstract | Al inicio del documento | ☐ |
|
||||||
| Cap. 1 Introducción | 1.1 Motivación, 1.2 Planteamiento, 1.3 Estructura | ☐ |
|
| Índice de contenidos | Auto-generado, nueva página | ☐ |
|
||||||
| Cap. 2 Contexto | 2.1 Contexto, 2.2 Estado del arte, 2.3 Conclusiones | ☐ |
|
| Índice de figuras | Auto-generado, nueva página | ☐ |
|
||||||
| Cap. 3 Objetivos | 3.1 Objetivo general, 3.2 Específicos, 3.3 Metodología | ☐ |
|
| Índice de tablas | Auto-generado, nueva página | ☐ |
|
||||||
| Cap. 4 Desarrollo | Structure depends on work type | ☐ |
|
| Índices separados | Contenidos/Figuras/Tablas en índices distintos | ☐ |
|
||||||
| Cap. 5 Conclusiones | 5.1 Conclusiones, 5.2 Trabajo futuro | ☐ |
|
| Índices en nueva página | Cada índice comienza en nueva página | ☐ |
|
||||||
| Referencias | APA format, alphabetical order | ☐ |
|
| Cap. 1 Introducción | 1.1 Motivación, 1.2 Planteamiento, 1.3 Estructura | ☐ |
|
||||||
| Anexos | Code repository URL, supplementary data | ☐ |
|
| Cap. 2 Contexto | 2.1 Contexto, 2.2 Estado del arte, 2.3 Conclusiones | ☐ |
|
||||||
|
| Cap. 3 Objetivos | 3.1 Objetivo general, 3.2 Específicos, 3.3 Metodología | ☐ |
|
||||||
## Tables
|
| Cap. 4 Desarrollo | Estructura según tipo de trabajo | ☐ |
|
||||||
|
| Cap. 5 Conclusiones | 5.1 Conclusiones, 5.2 Trabajo futuro | ☐ |
|
||||||
| Requirement | Specification | Check |
|
| Referencias | Formato APA, orden alfabético | ☐ |
|
||||||
|-------------|---------------|-------|
|
| Anexos | URL del repositorio y datos complementarios | ☐ |
|
||||||
| Title position | Above the table | ☐ |
|
| Índice de acrónimos | Incluir si se usan acrónimos | ☐ |
|
||||||
| Title format | **Tabla N.** *Descriptive title in italics.* | ☐ |
|
| Inicio de capítulos | Cada capítulo comienza en nueva página | ☐ |
|
||||||
| Numbering | Sequential (1, 2, 3...), Anexo uses A1, A2... | ☐ |
|
|
||||||
| Border style | APA: horizontal lines only (top, header bottom, table bottom) | ☐ |
|
## Tablas
|
||||||
| Source position | Below the table, centered | ☐ |
|
|
||||||
| Source format | Fuente: Author, Year. or Fuente: Elaboración propia. | ☐ |
|
| Requisito | Especificación | Check |
|
||||||
| Leyenda (if needed) | Below Fuente, same style (Piedefoto-tabla) | ☐ |
|
|-----------|----------------|-------|
|
||||||
| In TOT index | All tables appear in Índice de tablas | ☐ |
|
| Posición del título | Encima de la tabla | ☐ |
|
||||||
|
| Formato del título | **Tabla N.** *Título descriptivo en cursiva.* | ☐ |
|
||||||
## Figures
|
| Numeración | Secuencial (1, 2, 3...), Anexos usan A1, A2... | ☐ |
|
||||||
|
| Bordes | APA: solo líneas horizontales (superior, cabecera, inferior) | ☐ |
|
||||||
| Requirement | Specification | Check |
|
| Posición de fuente | Debajo de la tabla, centrada | ☐ |
|
||||||
|-------------|---------------|-------|
|
| Formato de fuente | Fuente: Autor, Año. o Fuente: Elaboración propia. | ☐ |
|
||||||
| Title position | Above the figure | ☐ |
|
| Leyenda (si aplica) | Debajo de Fuente, mismo estilo (Piedefoto-tabla) | ☐ |
|
||||||
| Title format | **Figura N.** *Descriptive title in italics.* | ☐ |
|
| En índice de tablas | Todas las tablas aparecen en el índice | ☐ |
|
||||||
| Numbering | Sequential (1, 2, 3...), Anexo uses A1, A2... | ☐ |
|
|
||||||
| Alignment | Centered | ☐ |
|
## Figuras
|
||||||
| Source position | Below the figure, centered | ☐ |
|
|
||||||
| Source format | Fuente: Author, Year. or Fuente: Elaboración propia. | ☐ |
|
| Requisito | Especificación | Check |
|
||||||
| Leyenda (if needed) | Below Fuente, same style (Piedefoto-tabla) | ☐ |
|
|-----------|----------------|-------|
|
||||||
| In TOF index | All figures appear in Índice de figuras | ☐ |
|
| Posición del título | Encima de la figura | ☐ |
|
||||||
|
| Formato del título | **Figura N.** *Título descriptivo en cursiva.* | ☐ |
|
||||||
## Lists
|
| Numeración | Secuencial (1, 2, 3...), Anexos usan A1, A2... | ☐ |
|
||||||
|
| Alineación | Centrada | ☐ |
|
||||||
| Requirement | Specification | Check |
|
| Posición de fuente | Debajo de la figura, centrada | ☐ |
|
||||||
|-------------|---------------|-------|
|
| Formato de fuente | Fuente: Autor, Año. o Fuente: Elaboración propia. | ☐ |
|
||||||
| Bullet lists | Indented 36pt, bullet symbol (·) | ☐ |
|
| Leyenda (si aplica) | Debajo de Fuente, mismo estilo (Piedefoto-tabla) | ☐ |
|
||||||
| Numbered lists | Indented 36pt, sequential numbers (1, 2, 3...) | ☐ |
|
| En índice de figuras | Todas las figuras aparecen en el índice | ☐ |
|
||||||
| Spacing | Proper First/Middle/Last paragraph spacing | ☐ |
|
|
||||||
|
## Listas
|
||||||
## Citations and References
|
|
||||||
|
| Requisito | Especificación | Check |
|
||||||
| Requirement | Specification | Check |
|
|-----------|----------------|-------|
|
||||||
|-------------|---------------|-------|
|
| Viñetas | Sangría 36 pt, símbolo de viñeta (·) | ☐ |
|
||||||
| Citation format | APA 7th edition | ☐ |
|
| Numeradas | Sangría 36 pt, numeración secuencial (1, 2, 3...) | ☐ |
|
||||||
| Single author | (Author, Year) or Author (Year) | ☐ |
|
| Espaciado | Uso correcto de First/Middle/Last | ☐ |
|
||||||
| Two authors | (Author1 & Author2, Year) | ☐ |
|
|
||||||
| Three+ authors | (Author1 et al., Year) | ☐ |
|
## Citas y referencias
|
||||||
| Reference list | Alphabetical by first author surname | ☐ |
|
|
||||||
| Hanging indent | 36pt left margin, -36pt text indent | ☐ |
|
| Requisito | Especificación | Check |
|
||||||
| DOI/URL | Include when available | ☐ |
|
|-----------|----------------|-------|
|
||||||
| No Wikipedia | Wikipedia citations not allowed | ☐ |
|
| Formato de citas | APA 7.ª edición | ☐ |
|
||||||
| Source variety | Books, journals, conferences (not just URLs) | ☐ |
|
| Un autor | (Autor, Año) o Autor (Año) | ☐ |
|
||||||
|
| Dos autores | (Autor1 & Autor2, Año) | ☐ |
|
||||||
## SMART Objectives
|
| Tres o más | (Autor1 et al., Año) | ☐ |
|
||||||
|
| Lista de referencias | Orden alfabético por apellido del primer autor | ☐ |
|
||||||
All objectives must be SMART:
|
| Sangría francesa | 36 pt margen izquierdo, -36 pt sangría | ☐ |
|
||||||
|
| DOI/URL | Incluir cuando esté disponible | ☐ |
|
||||||
| Criterion | Requirement | Check |
|
| Sin Wikipedia | No se permiten citas de Wikipedia | ☐ |
|
||||||
|-----------|-------------|-------|
|
| Variedad de fuentes | Libros, revistas, congresos (no solo URLs) | ☐ |
|
||||||
| **S**pecific | Clearly defined, unambiguous | ☐ |
|
| Referencias citadas | Toda referencia listada aparece en el texto | ☐ |
|
||||||
| **M**easurable | Quantifiable success metric (e.g., CER < 2%) | ☐ |
|
| Distribución de citas | La mayoría de citas en Cap. 2 (Estado del arte) | ☐ |
|
||||||
| **A**ttainable | Feasible with available resources | ☐ |
|
|
||||||
| **R**elevant | Demonstrable impact | ☐ |
|
## Objetivos SMART
|
||||||
| **T**ime-bound | Achievable within timeframe | ☐ |
|
|
||||||
|
Todos los objetivos deben ser SMART:
|
||||||
## Writing Style
|
|
||||||
|
| Criterio | Requisito | Check |
|
||||||
| Requirement | Check |
|
|----------|-----------|-------|
|
||||||
|-------------|-------|
|
| **S**pecific | Claramente definido, sin ambigüedad | ☐ |
|
||||||
| Each chapter starts with introductory paragraph | ☐ |
|
| **M**easurable | Métrica cuantificable (p. ej., CER < 2%) | ☐ |
|
||||||
| Each paragraph has at least 3 sentences | ☐ |
|
| **A**ttainable | Factible con los recursos disponibles | ☐ |
|
||||||
| No two consecutive headings without text between them | ☐ |
|
| **R**elevant | Impacto demostrable | ☐ |
|
||||||
| No superfluous phrases or repetition | ☐ |
|
| **T**ime-bound | Alcanzable en el plazo previsto | ☐ |
|
||||||
| All concepts defined with pertinent citations | ☐ |
|
|
||||||
| Spelling checked (Word corrector) | ☐ |
|
## Estilo de redacción
|
||||||
| Logical flow between paragraphs | ☐ |
|
|
||||||
|
| Requisito | Check |
|
||||||
## Final Checks
|
|-----------|-------|
|
||||||
|
| Cada capítulo inicia con un párrafo introductorio | ☐ |
|
||||||
| Requirement | Check |
|
| Cada párrafo tiene al menos 3 oraciones | ☐ |
|
||||||
|-------------|-------|
|
| No hay dos encabezados consecutivos sin texto entre ellos | ☐ |
|
||||||
| All cited references appear in reference list | ☐ |
|
| Sin frases superfluas ni repetición | ☐ |
|
||||||
| All references in list are cited in text | ☐ |
|
| Conceptos definidos con citas pertinentes | ☐ |
|
||||||
| All figures/tables have numbers and titles | ☐ |
|
| Ortografía revisada (corrector de Word) | ☐ |
|
||||||
| Update all indices (Ctrl+A, F9 in Word) | ☐ |
|
| Flujo lógico entre párrafos | ☐ |
|
||||||
| Page count: 50-90 pages (excl. cover, indices, annexes) | ☐ |
|
|
||||||
| Final format: PDF for deposit | ☐ |
|
## Comprobaciones finales
|
||||||
|
|
||||||
## Automated Checks (apply_content.py)
|
| Requisito | Check |
|
||||||
|
|-----------|-------|
|
||||||
The following are automatically handled by the generation scripts:
|
| Todas las citas aparecen en la lista de referencias | ☐ |
|
||||||
|
| Todas las referencias listadas se citan en el texto | ☐ |
|
||||||
- ✓ Table/Figure sequential numbering
|
| Todas las figuras/tablas tienen número y título | ☐ |
|
||||||
- ✓ Anexo items use A1, A2... prefix
|
| Actualizar índices (Ctrl+A, F9 en Word) | ☐ |
|
||||||
- ✓ TC fields for Anexo items (appear in indices)
|
| Extensión: 50-90 páginas (sin portada, resumen/abstract, índices y anexos) | ☐ |
|
||||||
- ✓ Piedefoto-tabla style for Fuente/Leyenda
|
| Formato final: PDF para depósito | ☐ |
|
||||||
- ✓ MsoCaption style for titles
|
|
||||||
- ✓ APA table borders (horizontal only)
|
## Comprobaciones automáticas (apply_content.py)
|
||||||
- ✓ MsoBibliography style for references
|
|
||||||
- ✓ MsoQuote style for blockquotes
|
Las siguientes tareas están automatizadas por los scripts de generación.
|
||||||
- ✓ List paragraph classes (First/Middle/Last)
|
|
||||||
- ✓ Bold H4 headings (unnumbered)
|
**Arquitectura modular:**
|
||||||
|
- `apply_content.py` - Orquestador principal (~300 líneas)
|
||||||
## Color Palette (UNIR Theme)
|
- `content_handlers.py` - Manejadores de bloques de contenido (~400 líneas)
|
||||||
|
- `markdown_utils.py` - Utilidades de markdown (~150 líneas)
|
||||||
| Color | Hex | Usage |
|
|
||||||
|-------|-----|-------|
|
**Funcionalidades automatizadas:**
|
||||||
| Primary Blue | `#0098CD` | Headings, diagram borders |
|
- ✓ Numeración secuencial de tablas y figuras
|
||||||
| Light Blue BG | `#E6F4F9` | Diagram backgrounds |
|
- ✓ Anexos con prefijo A1, A2...
|
||||||
| Dark Gray | `#404040` | Body text |
|
- ✓ Campos TC para anexos (aparecen en índices)
|
||||||
| Accent Blue | `#5B9BD5` | Table headers |
|
- ✓ Estilo Piedefoto-tabla para Fuente/Leyenda
|
||||||
| Light Accent | `#9CC2E5` | Table borders |
|
- ✓ Estilo MsoCaption para títulos
|
||||||
|
- ✓ Bordes APA en tablas (solo horizontales)
|
||||||
---
|
- ✓ Estilo MsoBibliography para referencias
|
||||||
|
- ✓ Estilo MsoQuote para citas textuales
|
||||||
**Reference:** UNIR TFE Guidelines (`instructions/instrucciones.pdf`, `instructions/plantilla_individual.pdf`)
|
- ✓ Estilos de listas (First/Middle/Last)
|
||||||
|
- ✓ Encabezados H4 en negrita (sin numeración)
|
||||||
|
|
||||||
|
## Paleta de color (tema UNIR)
|
||||||
|
|
||||||
|
| Color | Hex | Uso |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| Azul primario | `#0098CD` | Encabezados, bordes de diagramas |
|
||||||
|
| Azul claro (fondo) | `#E6F4F9` | Fondos de diagramas |
|
||||||
|
| Gris oscuro | `#404040` | Texto principal |
|
||||||
|
| Azul acento | `#5B9BD5` | Encabezados de tablas |
|
||||||
|
| Acento claro | `#9CC2E5` | Bordes de tablas |
|
||||||
|
|
||||||
|
## Reglas de redundancia entre capítulos
|
||||||
|
|
||||||
|
Al tratar el documento como un todo:
|
||||||
|
|
||||||
|
| Tipo de repetición | Aceptable | Motivo |
|
||||||
|
|--------------------|-----------|--------|
|
||||||
|
| Métricas clave en Resumen y Conclusiones | ✓ Sí | El resumen sintetiza el trabajo |
|
||||||
|
| Resultados del Cap. 4 resumidos en Cap. 5 | ✓ Sí | Estructura académica estándar |
|
||||||
|
| Figuras idénticas en capítulo principal y anexo | ✗ No | Usar referencias cruzadas |
|
||||||
|
| Mismos datos de tablas en varios capítulos | ✗ No | Referenciar tabla previa |
|
||||||
|
| Párrafos de relleno que repiten encabezados | ✗ No | Eliminar redundancia |
|
||||||
|
| “Síntesis” intermedia en un capítulo | ⚠ Con cuidado | Solo si el capítulo es largo |
|
||||||
|
|
||||||
|
**Antipatrones a evitar:**
|
||||||
|
- "En este capítulo se presenta..." seguido de "Las siguientes secciones describen..." (el encabezado ya lo indica)
|
||||||
|
- Diagramas Mermaid idénticos en varios capítulos (usar "Ver Figura N en Capítulo X")
|
||||||
|
- Repetir métricas exactas (CER 0.79%) más de 3 veces en capítulos principales
|
||||||
|
|
||||||
|
## Documento como un todo
|
||||||
|
|
||||||
|
Los archivos markdown (00-07) generan un único documento Word. Considerar:
|
||||||
|
|
||||||
|
| Aspecto | Guía |
|
||||||
|
|---------|------|
|
||||||
|
| Auditoría global | Evaluar redundancia y coherencia tratando 00-07 como un solo documento | ☐ |
|
||||||
|
| Numeración de tablas/figuras | Secuencial en TODOS los capítulos (no reiniciar por capítulo) |
|
||||||
|
| Referencias cruzadas | "Ver Tabla X" o "como se describió en la Sección 2.3" |
|
||||||
|
| Contenido de anexos | Detalles complementarios, no duplicados del cuerpo principal |
|
||||||
|
| Datos repetidos | Referenciar ubicación, no copiar |
|
||||||
|
|
||||||
|
## Reglas por capítulo
|
||||||
|
|
||||||
|
### Capítulo 1: Introducción (3-5 páginas)
|
||||||
|
|
||||||
|
| Sección | Debe incluir |
|
||||||
|
|---------|--------------|
|
||||||
|
| 1.1 Motivación | Identificación del problema, justificación e impacto, referencias previas |
|
||||||
|
| 1.2 Planteamiento | Enunciado breve del problema, solución propuesta, enfoque |
|
||||||
|
| 1.3 Estructura | Descripción breve de los capítulos siguientes |
|
||||||
|
|
||||||
|
### Capítulo 2: Contexto y Estado del Arte (10-15 páginas)
|
||||||
|
|
||||||
|
| Sección | Debe incluir |
|
||||||
|
|---------|--------------|
|
||||||
|
| 2.1 Contexto del problema | Estudio profundo del dominio de aplicación |
|
||||||
|
| 2.2 Estado del arte | Trabajos previos, estudios actuales, comparativas, autores clave |
|
||||||
|
| 2.3 Conclusiones | Síntesis que conecte con el trabajo a desarrollar |
|
||||||
|
|
||||||
|
### Capítulo 3: Objetivos y Metodología
|
||||||
|
|
||||||
|
| Sección | Debe incluir |
|
||||||
|
|---------|--------------|
|
||||||
|
| 3.1 Objetivo general | Objetivo SMART, efecto observable |
|
||||||
|
| 3.2 Objetivos específicos | 3-5 objetivos SMART, verbos en infinitivo |
|
||||||
|
| 3.3 Metodología | Pasos, instrumentos, métodos de análisis |
|
||||||
|
|
||||||
|
### Capítulo 4: Desarrollo Específico
|
||||||
|
|
||||||
|
Para trabajo híbrido tipo 1 (Experimental) + tipo 3 (Comparativo):
|
||||||
|
|
||||||
|
| Sección | Debe incluir |
|
||||||
|
|---------|--------------|
|
||||||
|
| 4.1 Planteamiento | Problema, alternativas, criterios de éxito |
|
||||||
|
| 4.2 Desarrollo | Resultados, mediciones, gráficos, tablas |
|
||||||
|
| 4.3 Discusión | Significado de los resultados, ventajas/desventajas |
|
||||||
|
|
||||||
|
### Capítulo 5: Conclusiones y Trabajo Futuro
|
||||||
|
|
||||||
|
| Sección | Debe incluir |
|
||||||
|
|---------|--------------|
|
||||||
|
| 5.1 Conclusiones | Síntesis de aportes, **relación con objetivos**, grado de logro |
|
||||||
|
| 5.2 Líneas de trabajo futuro | Extensiones futuras con justificación |
|
||||||
|
|
||||||
|
## Antipatrones de texto de relleno
|
||||||
|
|
||||||
|
Eliminar texto introductorio redundante:
|
||||||
|
|
||||||
|
| Patrón | Sustituir por |
|
||||||
|
|--------|---------------|
|
||||||
|
| "A lo largo de este capítulo se desarrolla..." | Contenido directo |
|
||||||
|
| "El presente capítulo constituye..." | Contenido directo |
|
||||||
|
| "Las siguientes secciones describen..." | Contenido directo |
|
||||||
|
| "Como se mencionó anteriormente..." | Referencia específica o eliminar |
|
||||||
|
| "### Introducción" bajo un apartado | Eliminar (el encabezado ya introduce) |
|
||||||
|
|
||||||
|
Frases de apertura válidas:
|
||||||
|
- Ir directo al contenido
|
||||||
|
|||||||
153
markdown_utils.py
Normal file
153
markdown_utils.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Utility functions for markdown processing and conversion."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from latex2mathml.converter import convert as latex_to_mathml
|
||||||
|
|
||||||
|
# Accept Fuente/Source lines with or without markdown bold
|
||||||
|
SOURCE_LINE_RE = re.compile(r'^\s*(?:\*{1,2})?(Fuente|Source):(?:\*{1,2})?\s*(.*)$', re.IGNORECASE)
|
||||||
|
# Accept Leyenda lines with or without markdown bold
|
||||||
|
LEYENDA_LINE_RE = re.compile(r'^\s*(?:\*{1,2})?Leyenda:(?:\*{1,2})?\s*(.*)$', re.IGNORECASE)
|
||||||
|
|
||||||
|
# Cross-reference patterns using markdown links:
|
||||||
|
# [Figura 15](#figura-15) or [Tabla 20](#tabla-20) -> Word REF fields
|
||||||
|
# Also supports Anexo: [Figura A1](#figura-a1), [Tabla A2](#tabla-a2)
|
||||||
|
CROSS_REF_LINK_RE = re.compile(r'\[(Figura|Tabla)\s+([A-Za-z]?\d+)\]\(#(figura|tabla)-([a-z]?\d+)\)', re.IGNORECASE)
|
||||||
|
# Section/chapter cross-reference patterns:
|
||||||
|
# [Sección 4.1](#seccion-4-1) or [Capítulo 2](#capitulo-2)
|
||||||
|
SECTION_REF_LINK_RE = re.compile(r'\[(Sección|Seccion|Capítulo|Capitulo)\s+([\d\.]+)\]\(#(seccion|capitulo)-([0-9-]+)\)', re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
"""Read file content with UTF-8 encoding, falling back to latin-1."""
|
||||||
|
try:
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
with open(path, 'r', encoding='latin-1') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path, content):
|
||||||
|
"""Write content to file with UTF-8 encoding."""
|
||||||
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_cross_references(text):
|
||||||
|
"""Convert markdown link cross-references to Word REF fields.
|
||||||
|
|
||||||
|
Supported syntax (renders normally in markdown viewers):
|
||||||
|
- [Figura 15](#figura-15) -> clickable Word cross-reference to Figure 15
|
||||||
|
- [Tabla 20](#tabla-20) -> clickable Word cross-reference to Table 20
|
||||||
|
- [Figura A1](#figura-a1) -> links to Anexo figures
|
||||||
|
- [Sección 4.1](#seccion-4-1) -> clickable link to Section 4.1
|
||||||
|
- [Capítulo 2](#capitulo-2) -> clickable link to Chapter 2
|
||||||
|
"""
|
||||||
|
def replace_fig_tab_ref(match):
|
||||||
|
display_type = match.group(1) # "Figura" or "Tabla"
|
||||||
|
display_num = match.group(2) # "15" or "A1"
|
||||||
|
|
||||||
|
if display_type.lower() == 'figura':
|
||||||
|
bookmark = f"_Ref_Fig{display_num}"
|
||||||
|
else: # Tabla
|
||||||
|
bookmark = f"_Ref_Tab{display_num}"
|
||||||
|
|
||||||
|
display_text = f"{display_type} {display_num}"
|
||||||
|
|
||||||
|
# Word REF field with \h for hyperlink
|
||||||
|
return f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> REF {bookmark} \\h <span style='mso-element:field-separator'></span><![endif]--><a href="#{bookmark}">{display_text}</a><!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->'''
|
||||||
|
|
||||||
|
def replace_section_ref(match):
|
||||||
|
display_type = match.group(1) # "Sección" or "Capítulo"
|
||||||
|
display_num = match.group(2) # "4.1" or "2"
|
||||||
|
anchor_num = match.group(4) # "4-1" or "2"
|
||||||
|
|
||||||
|
# Create bookmark name from anchor (e.g., 4-1 -> _Ref_Sec4_1)
|
||||||
|
bookmark = f"_Ref_Sec{anchor_num.replace('-', '_')}"
|
||||||
|
display_text = f"{display_type} {display_num}"
|
||||||
|
|
||||||
|
# Word REF field with \h for hyperlink
|
||||||
|
return f'''<!--[if supportFields]><span style='mso-element:field-begin'></span> REF {bookmark} \\h <span style='mso-element:field-separator'></span><![endif]--><a href="#{bookmark}">{display_text}</a><!--[if supportFields]><span style='mso-element:field-end'></span><![endif]-->'''
|
||||||
|
|
||||||
|
# Apply cross-reference conversions
|
||||||
|
text = CROSS_REF_LINK_RE.sub(replace_fig_tab_ref, text)
|
||||||
|
text = SECTION_REF_LINK_RE.sub(replace_section_ref, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def md_to_html_para(text):
|
||||||
|
"""Convert markdown inline formatting to HTML."""
|
||||||
|
# Cross-references (must be done before other conversions)
|
||||||
|
text = convert_cross_references(text)
|
||||||
|
# Bold
|
||||||
|
text = re.sub(r'\*\*([^*]+)\*\*', r'<b>\1</b>', text)
|
||||||
|
# Italic
|
||||||
|
text = re.sub(r'\*([^*]+)\*', r'<i>\1</i>', text)
|
||||||
|
# Inline code
|
||||||
|
text = re.sub(r'`([^`]+)`', r'<span style="font-family:Consolas;font-size:10pt">\1</span>', text)
|
||||||
|
# Links [text](url) -> <a href="url">text</a>
|
||||||
|
text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def convert_latex_formulas(text):
|
||||||
|
"""Convert LaTeX formulas to MathML for Word compatibility."""
|
||||||
|
# Block formulas $$...$$
|
||||||
|
def convert_block(match):
|
||||||
|
latex = match.group(1)
|
||||||
|
try:
|
||||||
|
mathml = latex_to_mathml(latex, display="block")
|
||||||
|
return f'<p class=MsoNormal style="text-align:center">{mathml}</p>'
|
||||||
|
except:
|
||||||
|
return match.group(0) # Keep original if conversion fails
|
||||||
|
|
||||||
|
text = re.sub(r'\$\$([^$]+)\$\$', convert_block, text)
|
||||||
|
|
||||||
|
# Inline formulas $...$
|
||||||
|
def convert_inline(match):
|
||||||
|
latex = match.group(1)
|
||||||
|
try:
|
||||||
|
return latex_to_mathml(latex, display="inline")
|
||||||
|
except:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
text = re.sub(r'\$([^$]+)\$', convert_inline, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_source_from_line(line):
|
||||||
|
"""Return source text if line is a Fuente/Source line, otherwise None."""
|
||||||
|
match = SOURCE_LINE_RE.match(line.strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(2).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_source_line(line):
|
||||||
|
"""Check whether a line starts with Fuente:/Source: (optionally bold)."""
|
||||||
|
return SOURCE_LINE_RE.match(line.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_leyenda_from_line(line):
|
||||||
|
"""Return leyenda text if line is a Leyenda line, otherwise None."""
|
||||||
|
match = LEYENDA_LINE_RE.match(line.strip())
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_leyenda_line(line):
|
||||||
|
"""Check whether a line starts with Leyenda: (optionally bold)."""
|
||||||
|
return LEYENDA_LINE_RE.match(line.strip()) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def split_into_paragraphs(text, lang='ES'):
|
||||||
|
"""Split text by double newlines and wrap each paragraph in <p> tags."""
|
||||||
|
paragraphs = []
|
||||||
|
for para in text.split('\n\n'):
|
||||||
|
para = para.strip()
|
||||||
|
if para:
|
||||||
|
formatted = md_to_html_para(para)
|
||||||
|
paragraphs.append(f'<p class=MsoNormal><span lang={lang}>{formatted}</span></p>')
|
||||||
|
return '\n'.join(paragraphs)
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "figura_12.png",
|
"file": "figura_12.png",
|
||||||
"title": "Reducción de errores: Baseline vs Optimizado (45 páginas)",
|
"title": "Reducción de errores (baseline vs optimizado)",
|
||||||
"index": 12
|
"index": 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,15 +78,5 @@
|
|||||||
"file": "figura_16.png",
|
"file": "figura_16.png",
|
||||||
"title": "Estructura del repositorio MastersThesis",
|
"title": "Estructura del repositorio MastersThesis",
|
||||||
"index": 16
|
"index": 16
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "figura_17.png",
|
|
||||||
"title": "Distribución de trials por rango de CER (PaddleOCR)",
|
|
||||||
"index": 17
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file": "figura_18.png",
|
|
||||||
"title": "Tiempo de procesamiento: CPU vs GPU (segundos/página)",
|
|
||||||
"index": 18
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user