Add: Coverage gutters
All checks were successful
Python CI / test (push) Successful in 42s

Remove: Un-used funcs
This commit is contained in:
Duncan Tourolle 2025-06-07 17:10:35 +02:00
parent afb22ccb5c
commit ed39f40bad
6 changed files with 4484 additions and 246 deletions

122
COVERAGE_GUTTERS_SETUP.md Normal file
View File

@ -0,0 +1,122 @@
# Coverage Gutters Setup Guide
This guide helps you set up Coverage Gutters in VSCode to visualize code coverage for the pyWebLayout project.
## Prerequisites
1. **Install VSCode Extension**: Make sure you have the "Coverage Gutters" extension installed in VSCode.
2. **Install Python packages**:
```bash
pip install pytest pytest-cov coverage
```
## Configuration Files
### 1. VSCode Settings (`.vscode/settings.json`)
Your VSCode settings are already configured with:
```json
{
"coverage-gutters.coverageBaseDir": "./",
"coverage-gutters.coverageFileNames": [
"coverage.xml",
"lcov.info",
"cov.xml",
"coverage.info"
],
"coverage-gutters.showGutterCoverage": true,
"coverage-gutters.showLineCoverage": true,
"coverage-gutters.showRulerCoverage": true,
"coverage-gutters.xmlname": "coverage.xml"
}
```
### 2. Coverage Configuration (`pyproject.toml`)
Coverage settings are configured in `pyproject.toml`:
```toml
[tool.coverage.run]
source = ["pyWebLayout"]
branch = true
omit = [
"*/tests/*",
"*/test_*",
"setup.py",
"*/examples/*",
"*/__main__.py"
]
[tool.coverage.xml]
output = "coverage.xml"
[tool.coverage.html]
directory = "htmlcov"
```
## How to Generate Coverage
### Option 1: Quick Coverage for Gutters
```bash
python run_coverage_gutters.py
```
### Option 2: Manual pytest command
```bash
python -m pytest tests/ --cov=pyWebLayout --cov-report=xml --cov-report=html --cov-report=term
```
### Option 3: Full coverage analysis
```bash
python scripts/run_coverage.py
```
## Using Coverage Gutters in VSCode
1. **Generate coverage data** using one of the options above
2. **Open VSCode** and navigate to your Python source files
3. **Enable Coverage Gutters**:
- Press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac)
- Type "Coverage Gutters: Display Coverage"
- Or click the "Watch" button in the status bar
## What You'll See
- **Green lines**: Code that is covered by tests
- **Red lines**: Code that is NOT covered by tests
- **Yellow lines**: Partially covered code (branches)
- **Coverage percentage** in the status bar
## Troubleshooting
1. **No coverage showing**:
- Make sure `coverage.xml` exists in the project root
- Check that the Coverage Gutters extension is enabled
- Try reloading VSCode window
2. **Coverage not updating**:
- Re-run the coverage command
- Click "Watch" in the status bar to refresh
3. **Tests not running**:
- Make sure you're in the project root directory
- Install missing dependencies: `pip install pytest pytest-cov`
## Coverage Files Generated
After running coverage, you should see:
- `coverage.xml` - XML format for Coverage Gutters
- `htmlcov/` - HTML coverage report directory
- `coverage.json` - JSON format for badges
- `.coverage` - Coverage database file
## Commands Summary
```bash
# Install dependencies
pip install pytest pytest-cov coverage
# Generate coverage for gutters
python run_coverage_gutters.py
# View HTML report
open htmlcov/index.html

4019
coverage.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,20 @@ from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from bs4 import BeautifulSoup, Tag, NavigableString from bs4 import BeautifulSoup, Tag, NavigableString
from pyWebLayout.abstract.inline import Word, FormattedSpan from pyWebLayout.abstract.inline import Word, FormattedSpan
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Block, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, Block,
HList, ListItem, ListStyle, Table, TableRow, TableCell, Paragraph,
HorizontalRule, Image Heading,
HeadingLevel,
Quote,
CodeBlock,
HList,
ListItem,
ListStyle,
Table,
TableRow,
TableCell,
HorizontalRule,
Image,
) )
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@ -23,34 +34,37 @@ class StyleContext(NamedTuple):
Immutable style context passed to handler functions. Immutable style context passed to handler functions.
Contains all styling information including inherited styles, CSS hints, and element attributes. Contains all styling information including inherited styles, CSS hints, and element attributes.
""" """
font: Font font: Font
background: Optional[Tuple[int, int, int, int]] background: Optional[Tuple[int, int, int, int]]
css_classes: set css_classes: set
css_styles: Dict[str, str] css_styles: Dict[str, str]
element_attributes: Dict[str, Any] element_attributes: Dict[str, Any]
parent_elements: List[str] # Stack of parent element names parent_elements: List[str] # Stack of parent element names
def with_font(self, font: Font) -> 'StyleContext': def with_font(self, font: Font) -> "StyleContext":
"""Create new context with modified font.""" """Create new context with modified font."""
return self._replace(font=font) return self._replace(font=font)
def with_background(self, background: Optional[Tuple[int, int, int, int]]) -> 'StyleContext': def with_background(
self, background: Optional[Tuple[int, int, int, int]]
) -> "StyleContext":
"""Create new context with modified background.""" """Create new context with modified background."""
return self._replace(background=background) return self._replace(background=background)
def with_css_classes(self, css_classes: set) -> 'StyleContext': def with_css_classes(self, css_classes: set) -> "StyleContext":
"""Create new context with modified CSS classes.""" """Create new context with modified CSS classes."""
return self._replace(css_classes=css_classes) return self._replace(css_classes=css_classes)
def with_css_styles(self, css_styles: Dict[str, str]) -> 'StyleContext': def with_css_styles(self, css_styles: Dict[str, str]) -> "StyleContext":
"""Create new context with modified CSS styles.""" """Create new context with modified CSS styles."""
return self._replace(css_styles=css_styles) return self._replace(css_styles=css_styles)
def with_attributes(self, attributes: Dict[str, Any]) -> 'StyleContext': def with_attributes(self, attributes: Dict[str, Any]) -> "StyleContext":
"""Create new context with modified element attributes.""" """Create new context with modified element attributes."""
return self._replace(element_attributes=attributes) return self._replace(element_attributes=attributes)
def push_element(self, element_name: str) -> 'StyleContext': def push_element(self, element_name: str) -> "StyleContext":
"""Create new context with element pushed onto parent stack.""" """Create new context with element pushed onto parent stack."""
return self._replace(parent_elements=self.parent_elements + [element_name]) return self._replace(parent_elements=self.parent_elements + [element_name])
@ -58,10 +72,10 @@ class StyleContext(NamedTuple):
def create_base_context(base_font: Optional[Font] = None) -> StyleContext: def create_base_context(base_font: Optional[Font] = None) -> StyleContext:
""" """
Create a base style context with default values. Create a base style context with default values.
Args: Args:
base_font: Base font to use, defaults to system default base_font: Base font to use, defaults to system default
Returns: Returns:
StyleContext with default values StyleContext with default values
""" """
@ -71,99 +85,105 @@ def create_base_context(base_font: Optional[Font] = None) -> StyleContext:
css_classes=set(), css_classes=set(),
css_styles={}, css_styles={},
element_attributes={}, element_attributes={},
parent_elements=[] parent_elements=[],
) )
def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext: def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
""" """
Apply element-specific styling to context based on HTML element and attributes. Apply element-specific styling to context based on HTML element and attributes.
Args: Args:
context: Current style context context: Current style context
element: BeautifulSoup Tag object element: BeautifulSoup Tag object
Returns: Returns:
New StyleContext with applied styling New StyleContext with applied styling
""" """
tag_name = element.name.lower() tag_name = element.name.lower()
attributes = dict(element.attrs) if element.attrs else {} attributes = dict(element.attrs) if element.attrs else {}
# Start with current context # Start with current context
new_context = context.with_attributes(attributes).push_element(tag_name) new_context = context.with_attributes(attributes).push_element(tag_name)
# Apply CSS classes # Apply CSS classes
css_classes = new_context.css_classes.copy() css_classes = new_context.css_classes.copy()
if 'class' in attributes: if "class" in attributes:
classes = attributes['class'].split() if isinstance(attributes['class'], str) else attributes['class'] classes = (
attributes["class"].split()
if isinstance(attributes["class"], str)
else attributes["class"]
)
css_classes.update(classes) css_classes.update(classes)
new_context = new_context.with_css_classes(css_classes) new_context = new_context.with_css_classes(css_classes)
# Apply inline styles # Apply inline styles
css_styles = new_context.css_styles.copy() css_styles = new_context.css_styles.copy()
if 'style' in attributes: if "style" in attributes:
inline_styles = parse_inline_styles(attributes['style']) inline_styles = parse_inline_styles(attributes["style"])
css_styles.update(inline_styles) css_styles.update(inline_styles)
new_context = new_context.with_css_styles(css_styles) new_context = new_context.with_css_styles(css_styles)
# Apply element-specific default styles # Apply element-specific default styles
font = apply_element_font_styles(new_context.font, tag_name, css_styles) font = apply_element_font_styles(new_context.font, tag_name, css_styles)
new_context = new_context.with_font(font) new_context = new_context.with_font(font)
# Apply background from styles # Apply background from styles
background = apply_background_styles(new_context.background, css_styles) background = apply_background_styles(new_context.background, css_styles)
new_context = new_context.with_background(background) new_context = new_context.with_background(background)
return new_context return new_context
def parse_inline_styles(style_text: str) -> Dict[str, str]: def parse_inline_styles(style_text: str) -> Dict[str, str]:
""" """
Parse CSS inline styles into dictionary. Parse CSS inline styles into dictionary.
Args: Args:
style_text: CSS style text (e.g., "color: red; font-weight: bold;") style_text: CSS style text (e.g., "color: red; font-weight: bold;")
Returns: Returns:
Dictionary of CSS property-value pairs Dictionary of CSS property-value pairs
""" """
styles = {} styles = {}
for declaration in style_text.split(';'): for declaration in style_text.split(";"):
if ':' in declaration: if ":" in declaration:
prop, value = declaration.split(':', 1) prop, value = declaration.split(":", 1)
styles[prop.strip().lower()] = value.strip() styles[prop.strip().lower()] = value.strip()
return styles return styles
def apply_element_font_styles(font: Font, tag_name: str, css_styles: Dict[str, str]) -> Font: def apply_element_font_styles(
font: Font, tag_name: str, css_styles: Dict[str, str]
) -> Font:
""" """
Apply font styling based on HTML element and CSS styles. Apply font styling based on HTML element and CSS styles.
Args: Args:
font: Current font font: Current font
tag_name: HTML tag name tag_name: HTML tag name
css_styles: CSS styles dictionary css_styles: CSS styles dictionary
Returns: Returns:
New Font object with applied styling New Font object with applied styling
""" """
# Default element styles # Default element styles
element_font_styles = { element_font_styles = {
'b': {'weight': FontWeight.BOLD}, "b": {"weight": FontWeight.BOLD},
'strong': {'weight': FontWeight.BOLD}, "strong": {"weight": FontWeight.BOLD},
'i': {'style': FontStyle.ITALIC}, "i": {"style": FontStyle.ITALIC},
'em': {'style': FontStyle.ITALIC}, "em": {"style": FontStyle.ITALIC},
'u': {'decoration': TextDecoration.UNDERLINE}, "u": {"decoration": TextDecoration.UNDERLINE},
's': {'decoration': TextDecoration.STRIKETHROUGH}, "s": {"decoration": TextDecoration.STRIKETHROUGH},
'del': {'decoration': TextDecoration.STRIKETHROUGH}, "del": {"decoration": TextDecoration.STRIKETHROUGH},
'h1': {'size': 24, 'weight': FontWeight.BOLD}, "h1": {"size": 24, "weight": FontWeight.BOLD},
'h2': {'size': 20, 'weight': FontWeight.BOLD}, "h2": {"size": 20, "weight": FontWeight.BOLD},
'h3': {'size': 18, 'weight': FontWeight.BOLD}, "h3": {"size": 18, "weight": FontWeight.BOLD},
'h4': {'size': 16, 'weight': FontWeight.BOLD}, "h4": {"size": 16, "weight": FontWeight.BOLD},
'h5': {'size': 14, 'weight': FontWeight.BOLD}, "h5": {"size": 14, "weight": FontWeight.BOLD},
'h6': {'size': 12, 'weight': FontWeight.BOLD}, "h6": {"size": 12, "weight": FontWeight.BOLD},
} }
# Start with current font properties # Start with current font properties
font_size = font.font_size font_size = font.font_size
colour = font.colour colour = font.colour
@ -172,70 +192,70 @@ def apply_element_font_styles(font: Font, tag_name: str, css_styles: Dict[str, s
decoration = font.decoration decoration = font.decoration
background = font.background background = font.background
language = font.language language = font.language
# Apply element default styles # Apply element default styles
if tag_name in element_font_styles: if tag_name in element_font_styles:
elem_styles = element_font_styles[tag_name] elem_styles = element_font_styles[tag_name]
if 'size' in elem_styles: if "size" in elem_styles:
font_size = elem_styles['size'] font_size = elem_styles["size"]
if 'weight' in elem_styles: if "weight" in elem_styles:
weight = elem_styles['weight'] weight = elem_styles["weight"]
if 'style' in elem_styles: if "style" in elem_styles:
style = elem_styles['style'] style = elem_styles["style"]
if 'decoration' in elem_styles: if "decoration" in elem_styles:
decoration = elem_styles['decoration'] decoration = elem_styles["decoration"]
# Apply CSS styles (override element defaults) # Apply CSS styles (override element defaults)
if 'font-size' in css_styles: if "font-size" in css_styles:
# Parse font-size (simplified - could be enhanced) # Parse font-size (simplified - could be enhanced)
size_value = css_styles['font-size'].lower() size_value = css_styles["font-size"].lower()
if size_value.endswith('px'): if size_value.endswith("px"):
try: try:
font_size = int(float(size_value[:-2])) font_size = int(float(size_value[:-2]))
except ValueError: except ValueError:
pass pass
elif size_value.endswith('pt'): elif size_value.endswith("pt"):
try: try:
font_size = int(float(size_value[:-2])) font_size = int(float(size_value[:-2]))
except ValueError: except ValueError:
pass pass
if 'font-weight' in css_styles: if "font-weight" in css_styles:
weight_value = css_styles['font-weight'].lower() weight_value = css_styles["font-weight"].lower()
if weight_value in ['bold', '700', '800', '900']: if weight_value in ["bold", "700", "800", "900"]:
weight = FontWeight.BOLD weight = FontWeight.BOLD
elif weight_value in ['normal', '400']: elif weight_value in ["normal", "400"]:
weight = FontWeight.NORMAL weight = FontWeight.NORMAL
if 'font-style' in css_styles: if "font-style" in css_styles:
style_value = css_styles['font-style'].lower() style_value = css_styles["font-style"].lower()
if style_value == 'italic': if style_value == "italic":
style = FontStyle.ITALIC style = FontStyle.ITALIC
elif style_value == 'normal': elif style_value == "normal":
style = FontStyle.NORMAL style = FontStyle.NORMAL
if 'text-decoration' in css_styles: if "text-decoration" in css_styles:
decoration_value = css_styles['text-decoration'].lower() decoration_value = css_styles["text-decoration"].lower()
if 'underline' in decoration_value: if "underline" in decoration_value:
decoration = TextDecoration.UNDERLINE decoration = TextDecoration.UNDERLINE
elif 'line-through' in decoration_value: elif "line-through" in decoration_value:
decoration = TextDecoration.STRIKETHROUGH decoration = TextDecoration.STRIKETHROUGH
elif 'none' in decoration_value: elif "none" in decoration_value:
decoration = TextDecoration.NONE decoration = TextDecoration.NONE
if 'color' in css_styles: if "color" in css_styles:
# Parse color (simplified - could be enhanced for hex, rgb, etc.) # Parse color (simplified - could be enhanced for hex, rgb, etc.)
color_value = css_styles['color'].lower() color_value = css_styles["color"].lower()
color_map = { color_map = {
'black': (0, 0, 0), "black": (0, 0, 0),
'white': (255, 255, 255), "white": (255, 255, 255),
'red': (255, 0, 0), "red": (255, 0, 0),
'green': (0, 255, 0), "green": (0, 255, 0),
'blue': (0, 0, 255), "blue": (0, 0, 255),
} }
if color_value in color_map: if color_value in color_map:
colour = color_map[color_value] colour = color_map[color_value]
elif color_value.startswith('#') and len(color_value) == 7: elif color_value.startswith("#") and len(color_value) == 7:
try: try:
r = int(color_value[1:3], 16) r = int(color_value[1:3], 16)
g = int(color_value[3:5], 16) g = int(color_value[3:5], 16)
@ -243,7 +263,7 @@ def apply_element_font_styles(font: Font, tag_name: str, css_styles: Dict[str, s
colour = (r, g, b) colour = (r, g, b)
except ValueError: except ValueError:
pass pass
return Font( return Font(
font_path=font._font_path, font_path=font._font_path,
font_size=font_size, font_size=font_size,
@ -252,44 +272,45 @@ def apply_element_font_styles(font: Font, tag_name: str, css_styles: Dict[str, s
style=style, style=style,
decoration=decoration, decoration=decoration,
background=background, background=background,
language=language language=language,
) )
def apply_background_styles(current_background: Optional[Tuple[int, int, int, int]], def apply_background_styles(
css_styles: Dict[str, str]) -> Optional[Tuple[int, int, int, int]]: current_background: Optional[Tuple[int, int, int, int]], css_styles: Dict[str, str]
) -> Optional[Tuple[int, int, int, int]]:
""" """
Apply background styling from CSS. Apply background styling from CSS.
Args: Args:
current_background: Current background color (RGBA) current_background: Current background color (RGBA)
css_styles: CSS styles dictionary css_styles: CSS styles dictionary
Returns: Returns:
New background color or None New background color or None
""" """
if 'background-color' in css_styles: if "background-color" in css_styles:
bg_value = css_styles['background-color'].lower() bg_value = css_styles["background-color"].lower()
if bg_value == 'transparent': if bg_value == "transparent":
return None return None
# Add color parsing logic here if needed # Add color parsing logic here if needed
return current_background return current_background
def extract_text_content(element: Tag, context: StyleContext) -> List[Word]: def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
""" """
Extract text content from an element, handling inline formatting. Extract text content from an element, handling inline formatting.
Args: Args:
element: BeautifulSoup Tag object element: BeautifulSoup Tag object
context: Current style context context: Current style context
Returns: Returns:
List of Word objects List of Word objects
""" """
words = [] words = []
for child in element.children: for child in element.children:
if isinstance(child, NavigableString): if isinstance(child, NavigableString):
# Plain text - split into words # Plain text - split into words
@ -301,7 +322,27 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
words.append(Word(word_text, context.font, context.background)) words.append(Word(word_text, context.font, context.background))
elif isinstance(child, Tag): elif isinstance(child, Tag):
# Process inline elements # Process inline elements
if child.name.lower() in ['span', 'a', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'mark', 'small', 'sub', 'sup', 'code', 'q', 'cite', 'abbr', 'time']: if child.name.lower() in [
"span",
"a",
"strong",
"b",
"em",
"i",
"u",
"s",
"del",
"ins",
"mark",
"small",
"sub",
"sup",
"code",
"q",
"cite",
"abbr",
"time",
]:
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
child_words = extract_text_content(child, child_context) child_words = extract_text_content(child, child_context)
words.extend(child_words) words.extend(child_words)
@ -317,18 +358,20 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
elif isinstance(child_result, Paragraph): elif isinstance(child_result, Paragraph):
for _, word in child_result.words(): for _, word in child_result.words():
words.append(word) words.append(word)
return words return words
def process_element(element: Tag, context: StyleContext) -> Union[Block, List[Block], None]: def process_element(
element: Tag, context: StyleContext
) -> Union[Block, List[Block], None]:
""" """
Process a single HTML element using appropriate handler. Process a single HTML element using appropriate handler.
Args: Args:
element: BeautifulSoup Tag object element: BeautifulSoup Tag object
context: Current style context context: Current style context
Returns: Returns:
Block object(s) or None if element should be ignored Block object(s) or None if element should be ignored
""" """
@ -340,6 +383,7 @@ def process_element(element: Tag, context: StyleContext) -> Union[Block, List[Bl
# Handler function signatures: # Handler function signatures:
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None] # All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph: def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
"""Handle <p> elements.""" """Handle <p> elements."""
paragraph = Paragraph(context.font) paragraph = Paragraph(context.font)
@ -367,14 +411,14 @@ def div_handler(element: Tag, context: StyleContext) -> List[Block]:
def heading_handler(element: Tag, context: StyleContext) -> Heading: def heading_handler(element: Tag, context: StyleContext) -> Heading:
"""Handle <h1>-<h6> elements.""" """Handle <h1>-<h6> elements."""
level_map = { level_map = {
'h1': HeadingLevel.H1, "h1": HeadingLevel.H1,
'h2': HeadingLevel.H2, "h2": HeadingLevel.H2,
'h3': HeadingLevel.H3, "h3": HeadingLevel.H3,
'h4': HeadingLevel.H4, "h4": HeadingLevel.H4,
'h5': HeadingLevel.H5, "h5": HeadingLevel.H5,
'h6': HeadingLevel.H6, "h6": HeadingLevel.H6,
} }
level = level_map.get(element.name.lower(), HeadingLevel.H1) level = level_map.get(element.name.lower(), HeadingLevel.H1)
heading = Heading(level, context.font) heading = Heading(level, context.font)
words = extract_text_content(element, context) words = extract_text_content(element, context)
@ -401,23 +445,23 @@ def blockquote_handler(element: Tag, context: StyleContext) -> Quote:
def preformatted_handler(element: Tag, context: StyleContext) -> CodeBlock: def preformatted_handler(element: Tag, context: StyleContext) -> CodeBlock:
"""Handle <pre> elements.""" """Handle <pre> elements."""
language = context.element_attributes.get('data-language', '') language = context.element_attributes.get("data-language", "")
code_block = CodeBlock(language) code_block = CodeBlock(language)
# Preserve whitespace and line breaks in preformatted text # Preserve whitespace and line breaks in preformatted text
text = element.get_text(separator='\n', strip=False) text = element.get_text(separator="\n", strip=False)
for line in text.split('\n'): for line in text.split("\n"):
code_block.add_line(line) code_block.add_line(line)
return code_block return code_block
def code_handler(element: Tag, context: StyleContext) -> Union[CodeBlock, None]: def code_handler(element: Tag, context: StyleContext) -> Union[CodeBlock, None]:
"""Handle <code> elements.""" """Handle <code> elements."""
# If parent is <pre>, this is handled by preformatted_handler # If parent is <pre>, this is handled by preformatted_handler
if context.parent_elements and context.parent_elements[-1] == 'pre': if context.parent_elements and context.parent_elements[-1] == "pre":
return None # Will be handled by parent return None # Will be handled by parent
# Inline code - handled during text extraction # Inline code - handled during text extraction
return None return None
@ -426,7 +470,7 @@ def unordered_list_handler(element: Tag, context: StyleContext) -> HList:
"""Handle <ul> elements.""" """Handle <ul> elements."""
hlist = HList(ListStyle.UNORDERED, context.font) hlist = HList(ListStyle.UNORDERED, context.font)
for child in element.children: for child in element.children:
if isinstance(child, Tag) and child.name.lower() == 'li': if isinstance(child, Tag) and child.name.lower() == "li":
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
item = process_element(child, child_context) item = process_element(child, child_context)
if item: if item:
@ -438,7 +482,7 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
"""Handle <ol> elements.""" """Handle <ol> elements."""
hlist = HList(ListStyle.ORDERED, context.font) hlist = HList(ListStyle.ORDERED, context.font)
for child in element.children: for child in element.children:
if isinstance(child, Tag) and child.name.lower() == 'li': if isinstance(child, Tag) and child.name.lower() == "li":
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
item = process_element(child, child_context) item = process_element(child, child_context)
if item: if item:
@ -449,7 +493,7 @@ def ordered_list_handler(element: Tag, context: StyleContext) -> HList:
def list_item_handler(element: Tag, context: StyleContext) -> ListItem: def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
"""Handle <li> elements.""" """Handle <li> elements."""
list_item = ListItem(None, context.font) list_item = ListItem(None, context.font)
for child in element.children: for child in element.children:
if isinstance(child, Tag): if isinstance(child, Tag):
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
@ -470,37 +514,37 @@ def list_item_handler(element: Tag, context: StyleContext) -> ListItem:
if word_text: if word_text:
paragraph.add_word(Word(word_text, context.font)) paragraph.add_word(Word(word_text, context.font))
list_item.add_block(paragraph) list_item.add_block(paragraph)
return list_item return list_item
def table_handler(element: Tag, context: StyleContext) -> Table: def table_handler(element: Tag, context: StyleContext) -> Table:
"""Handle <table> elements.""" """Handle <table> elements."""
caption = None caption = None
caption_elem = element.find('caption') caption_elem = element.find("caption")
if caption_elem: if caption_elem:
caption = caption_elem.get_text(strip=True) caption = caption_elem.get_text(strip=True)
table = Table(caption, context.font) table = Table(caption, context.font)
# Process table rows # Process table rows
for child in element.children: for child in element.children:
if isinstance(child, Tag): if isinstance(child, Tag):
if child.name.lower() == 'tr': if child.name.lower() == "tr":
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
row = process_element(child, child_context) row = process_element(child, child_context)
if row: if row:
table.add_row(row) table.add_row(row)
elif child.name.lower() in ['thead', 'tbody', 'tfoot']: elif child.name.lower() in ["thead", "tbody", "tfoot"]:
section = 'header' if child.name.lower() == 'thead' else 'body' section = "header" if child.name.lower() == "thead" else "body"
section = 'footer' if child.name.lower() == 'tfoot' else section section = "footer" if child.name.lower() == "tfoot" else section
for row_elem in child.find_all('tr'): for row_elem in child.find_all("tr"):
child_context = apply_element_styling(context, row_elem) child_context = apply_element_styling(context, row_elem)
row = process_element(row_elem, child_context) row = process_element(row_elem, child_context)
if row: if row:
table.add_row(row, section) table.add_row(row, section)
return table return table
@ -508,7 +552,7 @@ def table_row_handler(element: Tag, context: StyleContext) -> TableRow:
"""Handle <tr> elements.""" """Handle <tr> elements."""
row = TableRow(context.font) row = TableRow(context.font)
for child in element.children: for child in element.children:
if isinstance(child, Tag) and child.name.lower() in ['td', 'th']: if isinstance(child, Tag) and child.name.lower() in ["td", "th"]:
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
cell = process_element(child, child_context) cell = process_element(child, child_context)
if cell: if cell:
@ -518,10 +562,10 @@ def table_row_handler(element: Tag, context: StyleContext) -> TableRow:
def table_cell_handler(element: Tag, context: StyleContext) -> TableCell: def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
"""Handle <td> elements.""" """Handle <td> elements."""
colspan = int(context.element_attributes.get('colspan', 1)) colspan = int(context.element_attributes.get("colspan", 1))
rowspan = int(context.element_attributes.get('rowspan', 1)) rowspan = int(context.element_attributes.get("rowspan", 1))
cell = TableCell(False, colspan, rowspan, context.font) cell = TableCell(False, colspan, rowspan, context.font)
# Process cell content # Process cell content
for child in element.children: for child in element.children:
if isinstance(child, Tag): if isinstance(child, Tag):
@ -543,16 +587,16 @@ def table_cell_handler(element: Tag, context: StyleContext) -> TableCell:
if word_text: if word_text:
paragraph.add_word(Word(word_text, context.font)) paragraph.add_word(Word(word_text, context.font))
cell.add_block(paragraph) cell.add_block(paragraph)
return cell return cell
def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell: def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
"""Handle <th> elements.""" """Handle <th> elements."""
colspan = int(context.element_attributes.get('colspan', 1)) colspan = int(context.element_attributes.get("colspan", 1))
rowspan = int(context.element_attributes.get('rowspan', 1)) rowspan = int(context.element_attributes.get("rowspan", 1))
cell = TableCell(True, colspan, rowspan, context.font) cell = TableCell(True, colspan, rowspan, context.font)
# Process cell content (same as td) # Process cell content (same as td)
for child in element.children: for child in element.children:
if isinstance(child, Tag): if isinstance(child, Tag):
@ -573,7 +617,7 @@ def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell:
if word_text: if word_text:
paragraph.add_word(Word(word_text, context.font)) paragraph.add_word(Word(word_text, context.font))
cell.add_block(paragraph) cell.add_block(paragraph)
return cell return cell
@ -590,19 +634,19 @@ def line_break_handler(element: Tag, context: StyleContext) -> None:
def image_handler(element: Tag, context: StyleContext) -> Image: def image_handler(element: Tag, context: StyleContext) -> Image:
"""Handle <img> elements.""" """Handle <img> elements."""
src = context.element_attributes.get('src', '') src = context.element_attributes.get("src", "")
alt_text = context.element_attributes.get('alt', '') alt_text = context.element_attributes.get("alt", "")
# Parse dimensions if provided # Parse dimensions if provided
width = height = None width = height = None
try: try:
if 'width' in context.element_attributes: if "width" in context.element_attributes:
width = int(context.element_attributes['width']) width = int(context.element_attributes["width"])
if 'height' in context.element_attributes: if "height" in context.element_attributes:
height = int(context.element_attributes['height']) height = int(context.element_attributes["height"])
except ValueError: except ValueError:
pass pass
return Image(source=src, alt_text=alt_text, width=width, height=height) return Image(source=src, alt_text=alt_text, width=width, height=height)
@ -619,89 +663,87 @@ def generic_handler(element: Tag, context: StyleContext) -> List[Block]:
# Handler registry - maps HTML tag names to handler functions # Handler registry - maps HTML tag names to handler functions
HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None]]] = { HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None]]] = {
# Block elements # Block elements
'p': paragraph_handler, "p": paragraph_handler,
'div': div_handler, "div": div_handler,
'h1': heading_handler, "h1": heading_handler,
'h2': heading_handler, "h2": heading_handler,
'h3': heading_handler, "h3": heading_handler,
'h4': heading_handler, "h4": heading_handler,
'h5': heading_handler, "h5": heading_handler,
'h6': heading_handler, "h6": heading_handler,
'blockquote': blockquote_handler, "blockquote": blockquote_handler,
'pre': preformatted_handler, "pre": preformatted_handler,
'code': code_handler, "code": code_handler,
'ul': unordered_list_handler, "ul": unordered_list_handler,
'ol': ordered_list_handler, "ol": ordered_list_handler,
'li': list_item_handler, "li": list_item_handler,
'table': table_handler, "table": table_handler,
'tr': table_row_handler, "tr": table_row_handler,
'td': table_cell_handler, "td": table_cell_handler,
'th': table_header_cell_handler, "th": table_header_cell_handler,
'hr': horizontal_rule_handler, "hr": horizontal_rule_handler,
'br': line_break_handler, "br": line_break_handler,
# Semantic elements (treated as containers) # Semantic elements (treated as containers)
'section': div_handler, "section": div_handler,
'article': div_handler, "article": div_handler,
'aside': div_handler, "aside": div_handler,
'nav': div_handler, "nav": div_handler,
'header': div_handler, "header": div_handler,
'footer': div_handler, "footer": div_handler,
'main': div_handler, "main": div_handler,
'figure': div_handler, "figure": div_handler,
'figcaption': paragraph_handler, "figcaption": paragraph_handler,
# Media elements # Media elements
'img': image_handler, "img": image_handler,
# Inline elements (handled during text extraction) # Inline elements (handled during text extraction)
'span': ignore_handler, "span": ignore_handler,
'a': ignore_handler, "a": ignore_handler,
'strong': ignore_handler, "strong": ignore_handler,
'b': ignore_handler, "b": ignore_handler,
'em': ignore_handler, "em": ignore_handler,
'i': ignore_handler, "i": ignore_handler,
'u': ignore_handler, "u": ignore_handler,
's': ignore_handler, "s": ignore_handler,
'del': ignore_handler, "del": ignore_handler,
'ins': ignore_handler, "ins": ignore_handler,
'mark': ignore_handler, "mark": ignore_handler,
'small': ignore_handler, "small": ignore_handler,
'sub': ignore_handler, "sub": ignore_handler,
'sup': ignore_handler, "sup": ignore_handler,
'q': ignore_handler, "q": ignore_handler,
'cite': ignore_handler, "cite": ignore_handler,
'abbr': ignore_handler, "abbr": ignore_handler,
'time': ignore_handler, "time": ignore_handler,
# Ignored elements # Ignored elements
'script': ignore_handler, "script": ignore_handler,
'style': ignore_handler, "style": ignore_handler,
'meta': ignore_handler, "meta": ignore_handler,
'link': ignore_handler, "link": ignore_handler,
'head': ignore_handler, "head": ignore_handler,
'title': ignore_handler, "title": ignore_handler,
} }
def parse_html_string(html_string: str, base_font: Optional[Font] = None) -> List[Block]: def parse_html_string(
html_string: str, base_font: Optional[Font] = None
) -> List[Block]:
""" """
Parse HTML string and return list of Block objects. Parse HTML string and return list of Block objects.
Args: Args:
html_string: HTML content to parse html_string: HTML content to parse
base_font: Base font for styling, defaults to system default base_font: Base font for styling, defaults to system default
Returns: Returns:
List of Block objects representing the document structure List of Block objects representing the document structure
""" """
soup = BeautifulSoup(html_string, 'html.parser') soup = BeautifulSoup(html_string, "html.parser")
context = create_base_context(base_font) context = create_base_context(base_font)
blocks = [] blocks = []
# Process the body if it exists, otherwise process all top-level elements # Process the body if it exists, otherwise process all top-level elements
root_element = soup.find('body') or soup root_element = soup.find("body") or soup
for element in root_element.children: for element in root_element.children:
if isinstance(element, Tag): if isinstance(element, Tag):
element_context = apply_element_styling(context, element) element_context = apply_element_styling(context, element)
@ -711,29 +753,5 @@ def parse_html_string(html_string: str, base_font: Optional[Font] = None) -> Lis
blocks.extend(result) blocks.extend(result)
else: else:
blocks.append(result) blocks.append(result)
return blocks return blocks
def register_handler(tag_name: str, handler: Callable[[Tag, StyleContext], Union[Block, List[Block], None]]):
"""
Register a custom handler for an HTML tag.
Args:
tag_name: HTML tag name (lowercase)
handler: Handler function with signature (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
"""
HANDLERS[tag_name] = handler
def get_handler(tag_name: str) -> Callable[[Tag, StyleContext], Union[Block, List[Block], None]]:
"""
Get handler function for HTML tag.
Args:
tag_name: HTML tag name (lowercase)
Returns:
Handler function or generic_handler if tag not found
"""
return HANDLERS.get(tag_name.lower(), generic_handler)

View File

@ -18,3 +18,34 @@ dependencies = [
"pyphen", "pyphen",
"beautifulsoup4", "beautifulsoup4",
] ]
[tool.coverage.run]
source = ["pyWebLayout"]
branch = true
omit = [
"*/tests/*",
"*/test_*",
"setup.py",
"*/examples/*",
"*/__main__.py"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:"
]
precision = 2
show_missing = true
[tool.coverage.xml]
output = "coverage.xml"
[tool.coverage.html]
directory = "htmlcov"

48
run_coverage_gutters.py Normal file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
Simple coverage runner for Coverage Gutters extension.
Generates coverage.xml file needed by the VSCode Coverage Gutters extension.
"""
import subprocess
import sys
import os
def main():
"""Run coverage for Coverage Gutters."""
print("Generating coverage for Coverage Gutters...")
try:
# Run tests with coverage and generate XML report
cmd = [
sys.executable, "-m", "pytest",
"tests/",
"--cov=pyWebLayout",
"--cov-report=xml",
"--cov-report=term"
]
result = subprocess.run(cmd, check=True)
# Check if coverage.xml was created
if os.path.exists("coverage.xml"):
print("✓ coverage.xml generated successfully!")
print("Coverage Gutters should now be able to display coverage data.")
print("\nTo use Coverage Gutters in VSCode:")
print("1. Open Command Palette (Ctrl+Shift+P)")
print("2. Run 'Coverage Gutters: Display Coverage'")
print("3. Or use the Coverage Gutters buttons in the status bar")
else:
print("✗ coverage.xml was not generated")
except subprocess.CalledProcessError as e:
print(f"Error running tests: {e}")
sys.exit(1)
except FileNotFoundError:
print("pytest not found. Please install it with: pip install pytest pytest-cov")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -53,7 +53,7 @@ def main():
# Run tests with coverage # Run tests with coverage
print("\n2. Running tests with coverage...") print("\n2. Running tests with coverage...")
test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html" test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
run_command(test_cmd, "Running tests with coverage") run_command(test_cmd, "Running tests with coverage")
# Generate test coverage badge # Generate test coverage badge
@ -89,7 +89,7 @@ else:
# List generated files # List generated files
print("\n6. Generated files:") print("\n6. Generated files:")
files = ["coverage.svg", "coverage-docs.svg", "coverage-summary.txt", "htmlcov/", "coverage.json"] files = ["coverage.svg", "coverage-docs.svg", "coverage-summary.txt", "htmlcov/", "coverage.json", "coverage.xml"]
for file in files: for file in files:
if os.path.exists(file): if os.path.exists(file):
print(f"{file}") print(f"{file}")