diff --git a/.gitignore b/.gitignore index 156eec4..fe57ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ output.json .ruff-cache/ .mypy_cache/ ruff_report.txt +ruff-error.txt mypy_report.txt coverage.xml htmlcov/ \ No newline at end of file diff --git a/scripts/gen_json_schema.py b/scripts/gen_json_schema.py index a848939..a67bd3a 100644 --- a/scripts/gen_json_schema.py +++ b/scripts/gen_json_schema.py @@ -39,7 +39,14 @@ def _write_schema(name: str, model: type[BaseModel], output_dir: Path) -> Path: def main() -> int: - """Generate JSON Schemas for ExStruct public models.""" + """ + Generate JSON Schema files for ExStruct's public Pydantic models into the repository 'schemas' directory. + + Writes one JSON Schema file per public model into the 'schemas' folder at the project root. + + Returns: + exit_code (int): 0 on success. + """ project_root = Path(__file__).resolve().parent.parent output_dir = project_root / "schemas" targets: dict[str, type[BaseModel]] = { diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 1831937..88fcff4 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -12,14 +12,33 @@ def compute_line_angle_deg(w: float, h: float) -> float: - """Compute clockwise angle in Excel coordinates where 0 deg points East.""" + """ + Compute the clockwise angle (in degrees) in Excel coordinates where 0° points East. + + Parameters: + w (float): Horizontal delta (width, positive to the right). + h (float): Vertical delta (height, positive downward). + + Returns: + float: Angle in degrees measured clockwise from East (e.g., 0° = East, 90° = South). + """ return math.degrees(math.atan2(h, w)) % 360.0 def angle_to_compass( angle: float, ) -> Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"]: - """Convert angle to 8-point compass direction (0deg=E, 45deg=NE, 90deg=N, etc).""" + """ + Map an angle in degrees to one of eight compass directions. + + The angle is interpreted with 0 degrees at East and increasing values rotating counterclockwise (45 -> NE, 90 -> N). + + Parameters: + angle (float): Angle in degrees. + + Returns: + str: One of `"E"`, `"SE"`, `"S"`, `"SW"`, `"W"`, `"NW"`, `"N"`, or `"NE"` corresponding to the nearest 8-point compass direction. + """ dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"] idx = int(((angle + 22.5) % 360) // 45) return cast(Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"], dirs[idx]) @@ -28,7 +47,18 @@ def angle_to_compass( def coord_to_cell_by_edges( row_edges: list[float], col_edges: list[float], x: float, y: float ) -> str | None: - """Estimate cell address from coordinates and cumulative edges; return None if out of range.""" + """ + Estimate the Excel A1-style cell that contains a point given cumulative row and column edge coordinates. + + Parameters: + row_edges (list[float]): Monotonic list of cumulative vertical edges (top-to-bottom). Consecutive entries define row spans. + col_edges (list[float]): Monotonic list of cumulative horizontal edges (left-to-right). Consecutive entries define column spans. + x (float): Horizontal coordinate (same coordinate system as col_edges). + y (float): Vertical coordinate (same coordinate system as row_edges). + + Returns: + str | None: A1-style cell address (e.g., "B3") if the point falls inside the grid; `None` if the point is outside the provided edge ranges. Intervals are treated as left-inclusive and right-exclusive: [edge_i, edge_{i+1}). + """ def find_index(edges: list[float], pos: float) -> int | None: for i in range(1, len(edges)): @@ -82,10 +112,18 @@ def _should_include_shape( output_mode: str = "standard", ) -> bool: """ - Decide whether to emit a shape given output mode. - - standard: emit if text exists OR the shape is an arrow/line/connector. - - light: suppress shapes entirely (handled upstream, but guard defensively). - - verbose: include all (except already-filtered chart/comment/picture/form controls). + Determine whether a shape should be included in the output based on its properties and the selected output mode. + + Modes: + - "light": always exclude shapes. + - "standard": include when the shape has text or represents a relationship (line/connector). + - "verbose": include all shapes (other global exclusions are handled elsewhere). + + Parameters: + output_mode (str): One of "light", "standard", or "verbose"; controls inclusion rules. + + Returns: + bool: `True` if the shape should be emitted, `False` otherwise. """ if output_mode == "light": return False @@ -142,7 +180,12 @@ class _SmartArtLike(Protocol): def _shape_has_smartart(shp: xw.Shape) -> bool: - """Return True if the shape exposes SmartArt content.""" + """ + Determine whether a shape exposes SmartArt content. + + Returns: + bool: `True` if the shape exposes SmartArt (i.e., has an accessible `HasSmartArt` attribute), `False` otherwise. + """ try: api = shp.api except Exception: @@ -154,7 +197,12 @@ def _shape_has_smartart(shp: xw.Shape) -> bool: def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: - """Return SmartArt layout name or a fallback label.""" + """ + Get the SmartArt layout name or "Unknown" if it cannot be determined. + + Returns: + layout_name (str): The layout name from `smartart.Layout.Name`, or "Unknown" when `smartart` is None or the name cannot be retrieved. + """ if smartart is None: return "Unknown" try: @@ -168,7 +216,15 @@ def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: def _collect_smartart_node_info( smartart: _SmartArtLike | None, ) -> list[tuple[int, str]]: - """Collect (level, text) pairs from SmartArt nodes.""" + """ + Extract a list of (level, text) tuples for each node present in the given SmartArt. + + Parameters: + smartart (_SmartArtLike | None): A SmartArt-like COM object or `None`. If `None` or inaccessible, no nodes are collected. + + Returns: + list[tuple[int, str]]: A list of tuples where each tuple is (node level, node text). Returns an empty list if the SmartArt is `None`, inaccessible, or if nodes lack a numeric level. + """ nodes_info: list[tuple[int, str]] = [] if smartart is None: return nodes_info @@ -194,7 +250,12 @@ def _collect_smartart_node_info( def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: - """Return SmartArt node level or None when unavailable.""" + """ + Get the numerical level of a SmartArt node. + + Returns: + int | None: The node's level as an integer, or `None` if the level is missing or cannot be converted to an integer. + """ try: return int(node.Level) except Exception: @@ -202,7 +263,17 @@ def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: - """Build nested SmartArtNode roots from flat (level, text) tuples.""" + """ + Build a nested tree of SmartArtNode objects from a flat list of (level, text) tuples. + + Parameters: + nodes_info (list[tuple[int, str]]): Ordered tuples where each tuple is (level, text); + `level` is the hierarchical depth (integer) and `text` is the node label. + + Returns: + roots (list[SmartArtNode]): Top-level SmartArtNode instances whose `kids` lists + contain their nested child nodes according to the provided levels. + """ roots: list[SmartArtNode] = [] stack: list[tuple[int, SmartArtNode]] = [] for level, text in nodes_info: @@ -218,7 +289,15 @@ def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode]: - """Extract SmartArt nodes as nested roots.""" + """ + Convert a SmartArt COM object into a list of root SmartArtNode trees. + + Parameters: + smartart (_SmartArtLike | None): SmartArt-like COM object to extract nodes from; pass `None` to produce an empty list. + + Returns: + list[SmartArtNode]: Root nodes representing the hierarchical SmartArt structure (each node contains its text and children). + """ nodes_info = _collect_smartart_node_info(smartart) return _build_smartart_tree(nodes_info) @@ -226,7 +305,16 @@ def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode def get_shapes_with_position( # noqa: C901 workbook: Book, mode: str = "standard" ) -> dict[str, list[Shape | Arrow | SmartArt]]: - """Scan shapes in a workbook and return per-sheet shape lists with position info.""" + """ + Scan all shapes in each worksheet and collect their positional and metadata information. + + Parameters: + workbook (Book): The xlwings workbook to scan. + mode (str): Output detail level; "light" skips most shapes, "standard" includes shapes with text or relationships, and "verbose" includes full size/rotation details. + + Returns: + dict[str, list[Shape | Arrow | SmartArt]]: Mapping of sheet name to a list of collected shape objects (Shape, Arrow, or SmartArt) containing position (left/top), optional size (width/height), textual content, and other captured metadata (ids, directions, connections, layout/nodes for SmartArt). + """ shape_data: dict[str, list[Shape | Arrow | SmartArt]] = {} for sheet in workbook.sheets: shapes: list[Shape | Arrow | SmartArt] = [] diff --git a/src/exstruct/io/__init__.py b/src/exstruct/io/__init__.py index e2ad37a..4e7c83d 100644 --- a/src/exstruct/io/__init__.py +++ b/src/exstruct/io/__init__.py @@ -30,7 +30,17 @@ def dict_without_empty_values(obj: object) -> JsonStructure: - """Recursively drop empty values from nested structures.""" + """ + Remove None, empty string, empty list, and empty dict values from a nested structure or supported model object. + + Recursively processes dicts, lists, and supported model types (WorkbookData, CellRow, Chart, PrintArea, PrintAreaView, Shape, Arrow, SmartArt). Model instances are converted to dictionaries with None fields excluded before recursive cleaning. Values considered empty and removed are: `None`, `""` (empty string), `[]` (empty list), and `{}` (empty dict). + + Parameters: + obj (object): A value to clean; may be a dict, list, scalar, or one of the supported model instances. + + Returns: + JsonStructure: The input structure with empty values removed, preserving other values and nesting. + """ if isinstance(obj, dict): return { k: dict_without_empty_values(v) @@ -173,13 +183,37 @@ def _area_to_px_rect( def _rects_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool: - """Return True if rectangles (l, t, r, b) overlap.""" + """ + Determine whether two axis-aligned rectangles intersect (overlap in area). + + Parameters: + a (tuple[int, int, int, int]): Rectangle A as (left, top, right, bottom). + b (tuple[int, int, int, int]): Rectangle B as (left, top, right, bottom). + + Notes: + Rectangles are treated as half-open in this context: if they only touch at edges or corners, they do not count as overlapping. + + Returns: + bool: `True` if the rectangles have a non-zero-area intersection, `False` otherwise. + """ return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3]) def _filter_shapes_to_area( shapes: list[Shape | Arrow | SmartArt], area: PrintArea ) -> list[Shape | Arrow | SmartArt]: + """ + Filter drawable shapes to those that intersect the given print area. + + Shapes and the print area are compared in approximate pixel coordinates. Shapes that have both width and height are included when their bounding rectangle overlaps the area. Shapes with unknown size (width or height is None) are treated as a point at their left/top coordinates and included only if that point lies inside the area. + + Parameters: + shapes (list[Shape | Arrow | SmartArt]): Drawable objects with `l`, `t`, `w`, `h` coordinates. + area (PrintArea): Cell-based print area that will be converted to an approximate pixel rectangle. + + Returns: + list[Shape | Arrow | SmartArt]: Subset of `shapes` whose geometry intersects the print area. + """ area_rect = _area_to_px_rect(area) filtered: list[Shape | Arrow | SmartArt] = [] for shp in shapes: diff --git a/tests/com/test_shapes_extraction.py b/tests/com/test_shapes_extraction.py index be31e2d..a9b7a98 100644 --- a/tests/com/test_shapes_extraction.py +++ b/tests/com/test_shapes_extraction.py @@ -64,6 +64,15 @@ def _make_workbook_with_shapes(path: Path) -> None: def test_図形の種別とテキストが抽出される(tmp_path: Path) -> None: + """ + Verifies extraction of shape types, texts, IDs, and uniqueness from a workbook containing various shapes. + + Creates a workbook with a rectangle, an oval, a line, a nested group, and a connector, then extracts shapes from "Sheet1" and asserts: + - a shape with text "rect" is an AutoShape, has non-negative left/top coordinates, and a positive id; + - a nested child with text "inner" is not reported as a Group and has a positive id; + - all emitted shape ids are unique; + - no AutoShape without text is emitted in standard mode. + """ _ensure_excel() path = tmp_path / "shapes.xlsx" _make_workbook_with_shapes(path) @@ -92,6 +101,11 @@ def test_図形の種別とテキストが抽出される(tmp_path: Path) -> Non def test_線図形の方向と矢印情報が抽出される(tmp_path: Path) -> None: + """ + Verifies that a line shape's direction and arrow style information is extracted correctly from a workbook. + + Creates a workbook containing shapes, extracts shapes from "Sheet1", finds an Arrow with a begin or end arrow style, and asserts its direction is "E". + """ _ensure_excel() path = tmp_path / "lines.xlsx" _make_workbook_with_shapes(path) diff --git a/tests/core/test_mode_output.py b/tests/core/test_mode_output.py index 06e5930..f90bb5d 100644 --- a/tests/core/test_mode_output.py +++ b/tests/core/test_mode_output.py @@ -14,6 +14,12 @@ def _make_basic_book(path: Path) -> None: + """ + Create and save a simple Excel workbook with one sheet named "Sheet1" containing "v1" in A1 and "v2" in B1. + + Parameters: + path (Path): Filesystem path where the workbook will be saved. + """ wb = Workbook() ws = wb.active ws.title = "Sheet1" diff --git a/tests/core/test_shapes_positions_dummy.py b/tests/core/test_shapes_positions_dummy.py index 999e70b..8a8defb 100644 --- a/tests/core/test_shapes_positions_dummy.py +++ b/tests/core/test_shapes_positions_dummy.py @@ -43,6 +43,12 @@ def Line(self) -> _DummyLine: @property def Rotation(self) -> float: + """ + Get the shape's rotation angle. + + Returns: + rotation (float): Rotation angle in degrees. + """ return self.rotation @@ -52,18 +58,42 @@ class _DummyApiSmartArt: @property def Type(self) -> int: + """ + Get the shape's type identifier. + + Returns: + shape_type (int): Integer identifier for the shape type. + """ return self.shape_type @property def AutoShapeType(self) -> int: + """ + Indicates that an AutoShape type is unavailable for this API. + + Raises: + RuntimeError: Always raised with the message "AutoShapeType unavailable". + """ raise RuntimeError("AutoShapeType unavailable") @property def HasSmartArt(self) -> bool: + """ + Indicates whether the API represents a SmartArt shape. + + Returns: + bool: `True` if the shape is a SmartArt shape, `False` otherwise. + """ return True @property def SmartArt(self) -> object: + """ + Provide a generic placeholder object representing SmartArt details. + + Returns: + smartart (object): A generic placeholder object for SmartArt; its structure is not specified and should not be relied upon. + """ return object() diff --git a/tests/core/test_shapes_smartart_utils.py b/tests/core/test_shapes_smartart_utils.py index e8c49b0..2168013 100644 --- a/tests/core/test_shapes_smartart_utils.py +++ b/tests/core/test_shapes_smartart_utils.py @@ -46,6 +46,17 @@ class _DummyApi: class _DummyApiRaises: @property def HasSmartArt(self) -> bool: # noqa: N802 + """ + Indicates whether the shape contains SmartArt. + + This stub implementation is not available and raises an error when accessed. + + Returns: + `True` if the shape contains SmartArt, `False` otherwise. + + Raises: + RuntimeError: Always raised with the message "HasSmartArt unavailable". + """ raise RuntimeError("HasSmartArt unavailable") @@ -55,6 +66,12 @@ class _DummyShape: @property def api(self) -> object: + """ + Access the underlying API object for this shape. + + Returns: + The wrapped API object exposing the shape's underlying properties and methods. + """ return self.api_obj @@ -62,6 +79,15 @@ def api(self) -> object: class _DummyShapeRaisesApi: @property def api(self) -> object: + """ + Return the underlying API object for this wrapper. + + Returns: + object: The underlying API object. + + Raises: + RuntimeError: If the API is unavailable. + """ raise RuntimeError("api unavailable") diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index 8e61e90..f8b0914 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -16,6 +16,19 @@ def _workbook_with_print_area() -> WorkbookData: + """ + Create a sample WorkbookData containing a single sheet configured for print-area tests. + + The returned WorkbookData has book_name "book.xlsx" and a single sheet "Sheet1" whose SheetData contains: + - three rows with cells and one cell-level hyperlink, + - four shapes (two rectangle shapes, one SmartArt, one Arrow) positioned to allow in/out-of-area filtering, + - two charts (one inside the print area, one outside), + - table_candidates ["A1:B2", "C1:C1"], + - a single PrintArea spanning rows 1–2 and columns 0–1. + + Returns: + WorkbookData: The constructed workbook suitable for tests of print-area view extraction. + """ shape_inside = Shape(id=1, text="inside", l=10, t=5, w=20, h=10, type="Rect") shape_outside = Shape(id=2, text="outside", l=200, t=200, w=30, h=30, type="Rect") smartart_inside = SmartArt( diff --git a/tests/models/test_models_export.py b/tests/models/test_models_export.py index 9d110e5..4d96008 100644 --- a/tests/models/test_models_export.py +++ b/tests/models/test_models_export.py @@ -87,6 +87,11 @@ def test_sheet_to_toon_dependency() -> None: def test_workbook_iter_and_getitem() -> None: + """ + Verify Workbook supports lookup by sheet name and iteration over (name, sheet) pairs, and that missing sheet names raise KeyError. + + Asserts that retrieving a known sheet by key returns the SheetData instance, that iterating the workbook yields a single (name, sheet) pair matching the retrieved sheet, and that accessing a nonexistent sheet raises KeyError. + """ wb = _workbook() first = wb["Sheet1"] assert isinstance(first, SheetData) diff --git a/tests/models/test_models_validation.py b/tests/models/test_models_validation.py index 3bb45dd..a587390 100644 --- a/tests/models/test_models_validation.py +++ b/tests/models/test_models_validation.py @@ -80,6 +80,11 @@ def test_cellrowの数値正規化() -> None: def test_arrow_only_fields_are_not_on_shape() -> None: + """ + Ensure Arrow-specific identifier fields exist on Arrow instances and are absent from Shape instances. + + Verifies that an Arrow created with `begin_id` and `end_id` preserves those integer identifiers, and that a Shape does not expose `begin_id` or `end_id` attributes. + """ arrow = Arrow( id=None, text="a",