This commit is contained in:
parent
5fe4db4cbe
commit
496f3bf334
BIN
docs/images/example_06_html_table_with_images.png
Normal file
BIN
docs/images/example_06_html_table_with_images.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
318
examples/05_table_with_images_html.py
Normal file
318
examples/05_table_with_images_html.py
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Table with Images Example - HTML Output
|
||||||
|
|
||||||
|
This example demonstrates creating HTML tables with images:
|
||||||
|
- Creating HTML tables programmatically
|
||||||
|
- Embedding images in table cells
|
||||||
|
- Book catalog / product showcase tables
|
||||||
|
- Styled tables with CSS
|
||||||
|
- Mixed content (images and text) in cells
|
||||||
|
|
||||||
|
Generates standalone HTML files with embedded images (base64).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import base64
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
# Add pyWebLayout to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_base64(image_path: Path) -> str:
|
||||||
|
"""Convert image file to base64 string for HTML embedding."""
|
||||||
|
with open(image_path, 'rb') as img_file:
|
||||||
|
img_data = img_file.read()
|
||||||
|
return base64.b64encode(img_data).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def create_html_header(title: str) -> str:
|
||||||
|
"""Create HTML document header with CSS styles."""
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: 'DejaVu Sans', Arial, sans-serif;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.container {{
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: white;
|
||||||
|
padding: 30px;
|
||||||
|
border: 2px solid #b4b4b4;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h1 {{
|
||||||
|
color: #323296;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.table-container {{
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.table-title {{
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Blue style table */
|
||||||
|
table.style-blue {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #646464;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-blue th {{
|
||||||
|
background-color: #4682b4;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #646464;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-blue td {{
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #646464;
|
||||||
|
font-size: 11px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-blue tr:nth-child(even) {{
|
||||||
|
background-color: #f5f8fa;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-blue tr:nth-child(odd) {{
|
||||||
|
background-color: white;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Green style table */
|
||||||
|
table.style-green {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 2px solid #3c783c;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-green th {{
|
||||||
|
background-color: #90ee90;
|
||||||
|
color: #333;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 2px solid #3c783c;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-green td {{
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #3c783c;
|
||||||
|
font-size: 10px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-green tr:nth-child(even) {{
|
||||||
|
background-color: #f0fff0;
|
||||||
|
}}
|
||||||
|
|
||||||
|
table.style-green tr:nth-child(odd) {{
|
||||||
|
background-color: white;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.cover-image {{
|
||||||
|
max-width: 100px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.product-image {{
|
||||||
|
max-width: 120px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.price {{
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.footer {{
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_html_footer() -> str:
|
||||||
|
"""Create HTML document footer."""
|
||||||
|
return """
|
||||||
|
<div class="footer">
|
||||||
|
Generated by pyWebLayout - Table with Images Example (HTML Version)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_book_catalog_html(cover_images: Dict[str, str]) -> str:
|
||||||
|
"""Create HTML for book catalog table with cover images."""
|
||||||
|
books = [
|
||||||
|
("cover 1.png", "The Great Adventure", "John Smith", "$19.99"),
|
||||||
|
("cover 2.png", "Mystery of the Ages", "Jane Doe", "$24.99"),
|
||||||
|
("cover 3.png", "Science Today", "Dr. Brown", "$29.99"),
|
||||||
|
("cover 4.png", "Art & Design", "M. Artist", "$34.99"),
|
||||||
|
]
|
||||||
|
|
||||||
|
html = '<div class="table-container">\n'
|
||||||
|
html += ' <div class="table-title">Book Catalog</div>\n'
|
||||||
|
html += ' <table class="style-blue">\n'
|
||||||
|
html += ' <thead>\n'
|
||||||
|
html += ' <tr>\n'
|
||||||
|
html += ' <th>Cover</th>\n'
|
||||||
|
html += ' <th>Title</th>\n'
|
||||||
|
html += ' <th>Author</th>\n'
|
||||||
|
html += ' <th>Price</th>\n'
|
||||||
|
html += ' </tr>\n'
|
||||||
|
html += ' </thead>\n'
|
||||||
|
html += ' <tbody>\n'
|
||||||
|
|
||||||
|
for cover_file, title, author, price in books:
|
||||||
|
html += ' <tr>\n'
|
||||||
|
|
||||||
|
# Cover cell
|
||||||
|
html += ' <td>\n'
|
||||||
|
if cover_file in cover_images:
|
||||||
|
html += f' <img src="data:image/png;base64,{cover_images[cover_file]}" alt="{title}" class="cover-image">\n'
|
||||||
|
html += ' </td>\n'
|
||||||
|
|
||||||
|
# Title cell
|
||||||
|
html += f' <td>{title}</td>\n'
|
||||||
|
|
||||||
|
# Author cell
|
||||||
|
html += f' <td>{author}</td>\n'
|
||||||
|
|
||||||
|
# Price cell
|
||||||
|
html += f' <td class="price">{price}</td>\n'
|
||||||
|
|
||||||
|
html += ' </tr>\n'
|
||||||
|
|
||||||
|
html += ' </tbody>\n'
|
||||||
|
html += ' </table>\n'
|
||||||
|
html += '</div>\n'
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def create_product_showcase_html(cover_images: Dict[str, str]) -> str:
|
||||||
|
"""Create HTML for product showcase table."""
|
||||||
|
products = [
|
||||||
|
("cover 1.png", "Premium Edition - Hardcover with gold embossing"),
|
||||||
|
("cover 2.png", "Collector's Item - Limited print run"),
|
||||||
|
]
|
||||||
|
|
||||||
|
html = '<div class="table-container">\n'
|
||||||
|
html += ' <div class="table-title">Product Showcase</div>\n'
|
||||||
|
html += ' <table class="style-green">\n'
|
||||||
|
html += ' <thead>\n'
|
||||||
|
html += ' <tr>\n'
|
||||||
|
html += ' <th>Product</th>\n'
|
||||||
|
html += ' <th>Description</th>\n'
|
||||||
|
html += ' </tr>\n'
|
||||||
|
html += ' </thead>\n'
|
||||||
|
html += ' <tbody>\n'
|
||||||
|
|
||||||
|
for cover_file, description in products:
|
||||||
|
html += ' <tr>\n'
|
||||||
|
|
||||||
|
# Product cell with image
|
||||||
|
html += ' <td>\n'
|
||||||
|
if cover_file in cover_images:
|
||||||
|
html += f' <img src="data:image/png;base64,{cover_images[cover_file]}" alt="Product cover" class="product-image">\n'
|
||||||
|
html += ' </td>\n'
|
||||||
|
|
||||||
|
# Description cell
|
||||||
|
html += f' <td>{description}</td>\n'
|
||||||
|
|
||||||
|
html += ' </tr>\n'
|
||||||
|
|
||||||
|
html += ' </tbody>\n'
|
||||||
|
html += ' </table>\n'
|
||||||
|
html += '</div>\n'
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Generate HTML tables with images."""
|
||||||
|
print("Table with Images Example - HTML Version")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Load cover images and convert to base64
|
||||||
|
print("\n Loading and encoding cover images...")
|
||||||
|
cover_images = {}
|
||||||
|
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||||
|
|
||||||
|
for i in range(1, 5):
|
||||||
|
cover_path = data_path / f"cover {i}.png"
|
||||||
|
if cover_path.exists():
|
||||||
|
try:
|
||||||
|
cover_images[f"cover {i}.png"] = image_to_base64(cover_path)
|
||||||
|
print(f" ✓ Loaded and encoded cover {i}.png")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to load cover {i}.png: {e}")
|
||||||
|
|
||||||
|
if not cover_images:
|
||||||
|
print(" ✗ No cover images found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate HTML content
|
||||||
|
print("\n Generating HTML tables...")
|
||||||
|
html_content = create_html_header("Table with Images - HTML Example")
|
||||||
|
|
||||||
|
print(" - Creating book catalog table...")
|
||||||
|
html_content += create_book_catalog_html(cover_images)
|
||||||
|
|
||||||
|
print(" - Creating product showcase table...")
|
||||||
|
html_content += create_product_showcase_html(cover_images)
|
||||||
|
|
||||||
|
html_content += create_html_footer()
|
||||||
|
|
||||||
|
# Save HTML output
|
||||||
|
output_dir = Path("docs/html")
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = output_dir / "example_05_table_with_images.html"
|
||||||
|
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(html_content)
|
||||||
|
|
||||||
|
print(f"\n✓ Example completed!")
|
||||||
|
print(f" Output saved to: {output_path}")
|
||||||
|
print(f" Used {len(cover_images)} cover images (embedded as base64)")
|
||||||
|
print(f" Open the file in a web browser to view the tables")
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
273
examples/06_html_table_with_images.py
Normal file
273
examples/06_html_table_with_images.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
HTML Table with Images Example - End-to-End Rendering
|
||||||
|
|
||||||
|
This example demonstrates the complete pipeline:
|
||||||
|
1. HTML table source with <img> tags in cells
|
||||||
|
2. parse_html_string() converts HTML → Abstract document structure
|
||||||
|
3. DocumentLayouter handles all layout and rendering
|
||||||
|
|
||||||
|
No custom rendering code needed - DocumentLayouter handles everything!
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Add pyWebLayout to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||||
|
from pyWebLayout.concrete.table import TableStyle
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
|
||||||
|
|
||||||
|
def create_book_catalog_html():
|
||||||
|
"""Create HTML for a book catalog table with actual <img> tags."""
|
||||||
|
# Get base path for images - use absolute paths for the img src
|
||||||
|
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cover</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Price</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 1.png'}" alt="The Great Adventure" /></td>
|
||||||
|
<td>The Great Adventure</td>
|
||||||
|
<td>John Smith</td>
|
||||||
|
<td><b>$19.99</b></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 2.png'}" alt="Mystery of the Ages" /></td>
|
||||||
|
<td>Mystery of the Ages</td>
|
||||||
|
<td>Jane Doe</td>
|
||||||
|
<td><b>$24.99</b></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 3.png'}" alt="Science Today" /></td>
|
||||||
|
<td>Science Today</td>
|
||||||
|
<td>Dr. Brown</td>
|
||||||
|
<td><b>$29.99</b></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 4.png'}" alt="Art & Design" /></td>
|
||||||
|
<td>Art & Design</td>
|
||||||
|
<td>M. Artist</td>
|
||||||
|
<td><b>$34.99</b></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def create_product_showcase_html():
|
||||||
|
"""Create HTML for a product showcase table with images."""
|
||||||
|
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Product</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 1.png'}" alt="Premium Edition" /></td>
|
||||||
|
<td>Premium Edition - Hardcover with gold embossing</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><img src="{data_path / 'cover 2.png'}" alt="Collector's Item" /></td>
|
||||||
|
<td>Collector's Item - Limited print run</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def render_html_with_layouter(html_string: str, title: str,
|
||||||
|
table_style: TableStyle,
|
||||||
|
page_size=(600, 500)):
|
||||||
|
"""
|
||||||
|
Render HTML using DocumentLayouter - the proper way!
|
||||||
|
|
||||||
|
This function demonstrates the correct usage:
|
||||||
|
1. Parse HTML → Abstract blocks
|
||||||
|
2. Create Page
|
||||||
|
3. Create DocumentLayouter
|
||||||
|
4. Layout all blocks using layouter
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_string: HTML source containing table with <img> tags
|
||||||
|
title: Title for the output (for logging)
|
||||||
|
table_style: Table styling configuration
|
||||||
|
page_size: Page dimensions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL Image with rendered content
|
||||||
|
"""
|
||||||
|
print(f"\n Processing '{title}'...")
|
||||||
|
|
||||||
|
# Step 1: Parse HTML to abstract blocks
|
||||||
|
print(" 1. Parsing HTML → Abstract blocks...")
|
||||||
|
base_font = Font(font_size=11)
|
||||||
|
blocks = parse_html_string(html_string, base_font=base_font)
|
||||||
|
print(f" → Parsed {len(blocks)} blocks")
|
||||||
|
|
||||||
|
# Step 2: Create page
|
||||||
|
print(" 2. Creating page...")
|
||||||
|
page_style = PageStyle(
|
||||||
|
border_width=2,
|
||||||
|
border_color=(180, 180, 180),
|
||||||
|
padding=(20, 20, 20, 20),
|
||||||
|
background_color=(255, 255, 255)
|
||||||
|
)
|
||||||
|
page = Page(size=page_size, style=page_style)
|
||||||
|
|
||||||
|
# Step 3: Create DocumentLayouter
|
||||||
|
print(" 3. Creating DocumentLayouter...")
|
||||||
|
layouter = DocumentLayouter(page)
|
||||||
|
|
||||||
|
# Step 4: Layout all blocks using the layouter
|
||||||
|
print(" 4. Laying out all blocks...")
|
||||||
|
for block in blocks:
|
||||||
|
# For tables, we can pass a custom style
|
||||||
|
from pyWebLayout.abstract.block import Table
|
||||||
|
if isinstance(block, Table):
|
||||||
|
success = layouter.layout_table(block, style=table_style)
|
||||||
|
else:
|
||||||
|
# For other blocks (paragraphs, headings, images), use layout_document
|
||||||
|
success = layouter.layout_document([block])
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page")
|
||||||
|
|
||||||
|
print(f" ✓ Layout complete!")
|
||||||
|
|
||||||
|
# Step 5: Get the rendered canvas
|
||||||
|
# Note: Tables render directly onto page._canvas
|
||||||
|
# We access page.draw to ensure canvas is initialized
|
||||||
|
print(" 5. Getting rendered canvas...")
|
||||||
|
_ = page.draw # Ensure canvas exists
|
||||||
|
return page._canvas
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Demonstrate end-to-end HTML table with images rendering using DocumentLayouter."""
|
||||||
|
print("HTML Table with Images Example - DocumentLayouter")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\nThis example demonstrates:")
|
||||||
|
print(" 1. HTML with <img> tags inside <td> cells")
|
||||||
|
print(" 2. parse_html_string() automatically handles images")
|
||||||
|
print(" 3. DocumentLayouter handles all layout and rendering")
|
||||||
|
print(" 4. NO manual TableRenderer or custom rendering code!")
|
||||||
|
|
||||||
|
# Verify images exist
|
||||||
|
print("\n Checking for cover images...")
|
||||||
|
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||||
|
cover_count = 0
|
||||||
|
for i in range(1, 5):
|
||||||
|
cover_path = data_path / f"cover {i}.png"
|
||||||
|
if cover_path.exists():
|
||||||
|
cover_count += 1
|
||||||
|
print(f" ✓ Found cover {i}.png")
|
||||||
|
|
||||||
|
if cover_count == 0:
|
||||||
|
print(" ✗ No cover images found! This example requires cover images.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create HTML sources with <img> tags
|
||||||
|
print("\n Creating HTML sources with <img> tags...")
|
||||||
|
print(" - Book catalog HTML")
|
||||||
|
book_html = create_book_catalog_html()
|
||||||
|
|
||||||
|
print(" - Product showcase HTML")
|
||||||
|
product_html = create_product_showcase_html()
|
||||||
|
|
||||||
|
# Define table styles
|
||||||
|
book_style = TableStyle(
|
||||||
|
border_width=1,
|
||||||
|
border_color=(100, 100, 100),
|
||||||
|
cell_padding=(8, 10, 8, 10),
|
||||||
|
header_bg_color=(70, 130, 180),
|
||||||
|
cell_bg_color=(255, 255, 255),
|
||||||
|
alternate_row_color=(245, 248, 250)
|
||||||
|
)
|
||||||
|
|
||||||
|
product_style = TableStyle(
|
||||||
|
border_width=2,
|
||||||
|
border_color=(60, 120, 60),
|
||||||
|
cell_padding=(10, 12, 10, 12),
|
||||||
|
header_bg_color=(144, 238, 144),
|
||||||
|
cell_bg_color=(255, 255, 255),
|
||||||
|
alternate_row_color=(240, 255, 240)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render using DocumentLayouter - the proper way!
|
||||||
|
print("\n Rendering with DocumentLayouter (HTML → Abstract → Layout → PNG)...")
|
||||||
|
|
||||||
|
book_image = render_html_with_layouter(
|
||||||
|
book_html,
|
||||||
|
"Book Catalog",
|
||||||
|
book_style,
|
||||||
|
page_size=(700, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
product_image = render_html_with_layouter(
|
||||||
|
product_html,
|
||||||
|
"Product Showcase",
|
||||||
|
product_style,
|
||||||
|
page_size=(600, 350)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Combine images side by side
|
||||||
|
print("\n Combining output images...")
|
||||||
|
padding = 20
|
||||||
|
total_width = book_image.size[0] + product_image.size[0] + padding * 3
|
||||||
|
total_height = max(book_image.size[1], product_image.size[1]) + padding * 2
|
||||||
|
|
||||||
|
combined = Image.new('RGB', (total_width, total_height), (240, 240, 240))
|
||||||
|
combined.paste(book_image, (padding, padding))
|
||||||
|
combined.paste(product_image, (book_image.size[0] + padding * 2, padding))
|
||||||
|
|
||||||
|
# Save output
|
||||||
|
output_dir = Path("docs/images")
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = output_dir / "example_06_html_table_with_images.png"
|
||||||
|
combined.save(output_path)
|
||||||
|
|
||||||
|
print(f"\n✓ Example completed!")
|
||||||
|
print(f" Output saved to: {output_path}")
|
||||||
|
print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels")
|
||||||
|
print(f"\nThe complete pipeline:")
|
||||||
|
print(f" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
|
||||||
|
print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects")
|
||||||
|
print(f" 3. Page.render() → PNG output")
|
||||||
|
print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!")
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -5,8 +5,9 @@ from typing import List, Tuple, Optional, Union
|
|||||||
from pyWebLayout.concrete import Page, Line, Text
|
from pyWebLayout.concrete import Page, Line, Text
|
||||||
from pyWebLayout.concrete.image import RenderableImage
|
from pyWebLayout.concrete.image import RenderableImage
|
||||||
from pyWebLayout.concrete.functional import LinkText
|
from pyWebLayout.concrete.functional import LinkText
|
||||||
|
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak
|
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||||
from pyWebLayout.abstract.inline import LinkedWord
|
from pyWebLayout.abstract.inline import LinkedWord
|
||||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||||
from pyWebLayout.style import Font, Alignment
|
from pyWebLayout.style import Font, Alignment
|
||||||
@ -288,14 +289,65 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Layout a table within a given page.
|
||||||
|
|
||||||
|
This function uses the TableRenderer to render the table at the current
|
||||||
|
page position, advancing the page's y-offset after successful rendering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table: The abstract Table object to layout
|
||||||
|
page: The page to layout the table on
|
||||||
|
style: Optional table styling configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if table was successfully laid out, False if page ran out of space
|
||||||
|
"""
|
||||||
|
# Calculate available space
|
||||||
|
available_width = page.available_width
|
||||||
|
x_offset = page.border_size
|
||||||
|
y_offset = page._current_y_offset
|
||||||
|
|
||||||
|
# Access page.draw to ensure canvas is initialized
|
||||||
|
draw = page.draw
|
||||||
|
canvas = page._canvas
|
||||||
|
|
||||||
|
# Create table renderer
|
||||||
|
origin = (x_offset, y_offset)
|
||||||
|
renderer = TableRenderer(
|
||||||
|
table=table,
|
||||||
|
origin=origin,
|
||||||
|
available_width=available_width,
|
||||||
|
draw=draw,
|
||||||
|
style=style,
|
||||||
|
canvas=canvas
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if table fits on current page
|
||||||
|
table_height = renderer.size[1]
|
||||||
|
available_height = page.size[1] - y_offset - page.border_size
|
||||||
|
|
||||||
|
if table_height > available_height:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Render the table
|
||||||
|
renderer.render()
|
||||||
|
|
||||||
|
# Update page y-offset
|
||||||
|
page._current_y_offset = y_offset + table_height
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DocumentLayouter:
|
class DocumentLayouter:
|
||||||
"""
|
"""
|
||||||
Document layouter that orchestrates layout of various abstract elements.
|
Document layouter that orchestrates layout of various abstract elements.
|
||||||
|
|
||||||
Delegates to specialized layouters for different content types:
|
Delegates to specialized layouters for different content types:
|
||||||
- paragraph_layouter for text paragraphs
|
- paragraph_layouter for text paragraphs
|
||||||
- image_layouter for images (future)
|
- image_layouter for images
|
||||||
- table_layouter for tables (future)
|
- table_layouter for tables
|
||||||
|
|
||||||
This class acts as a coordinator, managing the overall document flow
|
This class acts as a coordinator, managing the overall document flow
|
||||||
and page context while delegating specific layout tasks to specialized
|
and page context while delegating specific layout tasks to specialized
|
||||||
@ -310,7 +362,15 @@ class DocumentLayouter:
|
|||||||
page: The page to layout content on
|
page: The page to layout content on
|
||||||
"""
|
"""
|
||||||
self.page = page
|
self.page = page
|
||||||
self.style_registry = ConcreteStyleRegistry(page.style_resolver)
|
# Create a style resolver if page doesn't have one
|
||||||
|
if hasattr(page, 'style_resolver'):
|
||||||
|
style_resolver = page.style_resolver
|
||||||
|
else:
|
||||||
|
# Create a default rendering context and style resolver
|
||||||
|
from pyWebLayout.style.concrete_style import RenderingContext
|
||||||
|
context = RenderingContext()
|
||||||
|
style_resolver = StyleResolver(context)
|
||||||
|
self.style_registry = ConcreteStyleRegistry(style_resolver)
|
||||||
|
|
||||||
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
|
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
|
||||||
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||||
@ -342,14 +402,27 @@ class DocumentLayouter:
|
|||||||
"""
|
"""
|
||||||
return image_layouter(image, self.page, max_width, max_height)
|
return image_layouter(image, self.page, max_width, max_height)
|
||||||
|
|
||||||
def layout_document(self, elements: List[Union[Paragraph, AbstractImage]]) -> bool:
|
def layout_table(self, table: Table, style: Optional[TableStyle] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Layout a list of abstract elements (paragraphs and images).
|
Layout a table using the table_layouter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
table: The abstract Table object to layout
|
||||||
|
style: Optional table styling configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if table was successfully laid out, False if page ran out of space
|
||||||
|
"""
|
||||||
|
return table_layouter(table, self.page, style)
|
||||||
|
|
||||||
|
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table]]) -> bool:
|
||||||
|
"""
|
||||||
|
Layout a list of abstract elements (paragraphs, images, and tables).
|
||||||
|
|
||||||
This method delegates to specialized layouters based on element type:
|
This method delegates to specialized layouters based on element type:
|
||||||
- Paragraphs are handled by layout_paragraph
|
- Paragraphs are handled by layout_paragraph
|
||||||
- Images are handled by layout_image
|
- Images are handled by layout_image
|
||||||
- Tables and other elements can be added in the future
|
- Tables are handled by layout_table
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
elements: List of abstract elements to layout
|
elements: List of abstract elements to layout
|
||||||
@ -366,6 +439,9 @@ class DocumentLayouter:
|
|||||||
success = self.layout_image(element)
|
success = self.layout_image(element)
|
||||||
if not success:
|
if not success:
|
||||||
return False
|
return False
|
||||||
# Future: elif isinstance(element, Table): use table_layouter
|
elif isinstance(element, Table):
|
||||||
|
success = self.layout_table(element)
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
# Future: elif isinstance(element, CodeBlock): use code_layouter
|
# Future: elif isinstance(element, CodeBlock): use code_layouter
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import pytest
|
|||||||
from unittest.mock import Mock, MagicMock, patch
|
from unittest.mock import Mock, MagicMock, patch
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
|
from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter
|
||||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||||
|
from pyWebLayout.abstract.block import Table, TableRow, TableCell
|
||||||
|
from pyWebLayout.concrete.table import TableStyle
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentLayouter:
|
class TestDocumentLayouter:
|
||||||
@ -577,6 +579,179 @@ class TestMultiPageLayout:
|
|||||||
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTableLayouter:
|
||||||
|
"""Test cases for table layouter functionality."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test fixtures before each test method."""
|
||||||
|
# Create mock page
|
||||||
|
self.mock_page = Mock()
|
||||||
|
self.mock_page.border_size = 20
|
||||||
|
self.mock_page._current_y_offset = 50
|
||||||
|
self.mock_page.available_width = 600
|
||||||
|
self.mock_page.size = (800, 1000)
|
||||||
|
|
||||||
|
# Create mock draw and canvas
|
||||||
|
self.mock_draw = Mock()
|
||||||
|
self.mock_canvas = Mock()
|
||||||
|
self.mock_page.draw = self.mock_draw
|
||||||
|
self.mock_page._canvas = self.mock_canvas
|
||||||
|
|
||||||
|
# Create mock table
|
||||||
|
self.mock_table = Mock(spec=Table)
|
||||||
|
|
||||||
|
# Create mock style resolver
|
||||||
|
self.mock_style_resolver = Mock()
|
||||||
|
self.mock_page.style_resolver = self.mock_style_resolver
|
||||||
|
|
||||||
|
@patch('pyWebLayout.layout.document_layouter.TableRenderer')
|
||||||
|
def test_table_layouter_success(self, mock_table_renderer_class):
|
||||||
|
"""Test table_layouter with successful table rendering."""
|
||||||
|
# Setup mock renderer
|
||||||
|
mock_renderer = Mock()
|
||||||
|
mock_table_renderer_class.return_value = mock_renderer
|
||||||
|
mock_renderer.size = (500, 200) # Table fits on page
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
result = table_layouter(self.mock_table, self.mock_page)
|
||||||
|
|
||||||
|
# Verify results
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify TableRenderer was created with correct parameters
|
||||||
|
mock_table_renderer_class.assert_called_once_with(
|
||||||
|
table=self.mock_table,
|
||||||
|
origin=(20, 50), # (border_size, current_y_offset)
|
||||||
|
available_width=600,
|
||||||
|
draw=self.mock_draw,
|
||||||
|
style=None,
|
||||||
|
canvas=self.mock_canvas
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify render was called
|
||||||
|
mock_renderer.render.assert_called_once()
|
||||||
|
|
||||||
|
# Verify y_offset was updated
|
||||||
|
assert self.mock_page._current_y_offset == 250 # 50 + 200
|
||||||
|
|
||||||
|
@patch('pyWebLayout.layout.document_layouter.TableRenderer')
|
||||||
|
def test_table_layouter_with_custom_style(self, mock_table_renderer_class):
|
||||||
|
"""Test table_layouter with custom TableStyle."""
|
||||||
|
# Create custom style
|
||||||
|
custom_style = TableStyle(
|
||||||
|
border_width=2,
|
||||||
|
border_color=(100, 100, 100),
|
||||||
|
cell_padding=(10, 10, 10, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Setup mock renderer
|
||||||
|
mock_renderer = Mock()
|
||||||
|
mock_table_renderer_class.return_value = mock_renderer
|
||||||
|
mock_renderer.size = (500, 150)
|
||||||
|
|
||||||
|
# Call function with style
|
||||||
|
result = table_layouter(self.mock_table, self.mock_page, style=custom_style)
|
||||||
|
|
||||||
|
# Verify TableRenderer was created with custom style
|
||||||
|
assert result is True
|
||||||
|
call_args = mock_table_renderer_class.call_args
|
||||||
|
assert call_args[1]['style'] == custom_style
|
||||||
|
|
||||||
|
@patch('pyWebLayout.layout.document_layouter.TableRenderer')
|
||||||
|
def test_table_layouter_table_too_large(self, mock_table_renderer_class):
|
||||||
|
"""Test table_layouter when table doesn't fit on page."""
|
||||||
|
# Setup mock renderer with table larger than available space
|
||||||
|
mock_renderer = Mock()
|
||||||
|
mock_table_renderer_class.return_value = mock_renderer
|
||||||
|
mock_renderer.size = (500, 1000) # Table height exceeds available space
|
||||||
|
|
||||||
|
# Available height = page_size[1] - y_offset - border_size
|
||||||
|
# = 1000 - 50 - 20 = 930, but table is 1000 pixels tall
|
||||||
|
|
||||||
|
# Call function
|
||||||
|
result = table_layouter(self.mock_table, self.mock_page)
|
||||||
|
|
||||||
|
# Verify failure
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
# Verify render was NOT called
|
||||||
|
mock_renderer.render.assert_not_called()
|
||||||
|
|
||||||
|
# Verify y_offset was NOT updated
|
||||||
|
assert self.mock_page._current_y_offset == 50
|
||||||
|
|
||||||
|
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||||
|
def test_document_layouter_layout_table(self, mock_style_registry_class):
|
||||||
|
"""Test DocumentLayouter.layout_table method."""
|
||||||
|
# Setup mocks
|
||||||
|
mock_style_registry = Mock()
|
||||||
|
mock_style_registry_class.return_value = mock_style_registry
|
||||||
|
|
||||||
|
layouter = DocumentLayouter(self.mock_page)
|
||||||
|
|
||||||
|
# Mock the table_layouter function
|
||||||
|
with patch('pyWebLayout.layout.document_layouter.table_layouter') as mock_table_layouter:
|
||||||
|
mock_table_layouter.return_value = True
|
||||||
|
|
||||||
|
custom_style = TableStyle(border_width=1)
|
||||||
|
result = layouter.layout_table(self.mock_table, style=custom_style)
|
||||||
|
|
||||||
|
# Verify the function was called correctly
|
||||||
|
mock_table_layouter.assert_called_once_with(
|
||||||
|
self.mock_table, self.mock_page, custom_style
|
||||||
|
)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_document_layouter_layout_document_with_table(self):
|
||||||
|
"""Test DocumentLayouter.layout_document with Table elements."""
|
||||||
|
from pyWebLayout.abstract import Paragraph
|
||||||
|
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||||
|
|
||||||
|
# Create mixed elements
|
||||||
|
elements = [
|
||||||
|
Mock(spec=Paragraph),
|
||||||
|
Mock(spec=Table),
|
||||||
|
Mock(spec=AbstractImage),
|
||||||
|
Mock(spec=Table)
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||||
|
layouter = DocumentLayouter(self.mock_page)
|
||||||
|
|
||||||
|
# Mock the layout methods
|
||||||
|
layouter.layout_paragraph = Mock(return_value=(True, None, None))
|
||||||
|
layouter.layout_table = Mock(return_value=True)
|
||||||
|
layouter.layout_image = Mock(return_value=True)
|
||||||
|
|
||||||
|
result = layouter.layout_document(elements)
|
||||||
|
|
||||||
|
# Verify all elements were laid out
|
||||||
|
assert result is True
|
||||||
|
assert layouter.layout_paragraph.call_count == 1
|
||||||
|
assert layouter.layout_table.call_count == 2
|
||||||
|
assert layouter.layout_image.call_count == 1
|
||||||
|
|
||||||
|
def test_document_layouter_layout_document_table_failure(self):
|
||||||
|
"""Test DocumentLayouter.layout_document when table layout fails."""
|
||||||
|
# Create elements with table that will fail
|
||||||
|
elements = [
|
||||||
|
Mock(spec=Table),
|
||||||
|
Mock(spec=Table)
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||||
|
layouter = DocumentLayouter(self.mock_page)
|
||||||
|
|
||||||
|
# Mock layout_table: first succeeds, second fails
|
||||||
|
layouter.layout_table = Mock(side_effect=[True, False])
|
||||||
|
|
||||||
|
result = layouter.layout_document(elements)
|
||||||
|
|
||||||
|
# Verify it stopped after failure
|
||||||
|
assert result is False
|
||||||
|
assert layouter.layout_table.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Run specific tests for debugging
|
# Run specific tests for debugging
|
||||||
test = TestDocumentLayouter()
|
test = TestDocumentLayouter()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user