From a260e9551ba97af92a1d1c3a460fdeb098f23c0f Mon Sep 17 00:00:00 2001 From: Pete Story Date: Fri, 23 Oct 2026 14:29:39 +0200 Subject: [PATCH 1/4] Create a utility that can move the contents of a page to take account of the fact that the page has been moved from the left side to the right side of a double page or vice versa --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df622af6..77d9ae81 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ nosetests.xml /tests/testCalendar/a4l/*.pdf /tests/testCalendar/a4p/*.pdf /tests/testIndexLarge/*.mcf.*.idx.*.png +/utilities/fixleftrightchanges/tests From 17a35009201c9cde324ae8ce841b7889a4fe6b4e Mon Sep 17 00:00:00 2001 From: Pete Story Date: Fri, 23 Oct 2026 14:30:11 +0200 Subject: [PATCH 2/4] Create a utility that can move the contents of a page to take account of the fact that the page has been moved from the left side to the right side of a double page or vice versa --- README.md | 8 + documents/build_a_compiled_version.md | 7 +- .../test_reposition_mcf_layout.py | 179 +++++++++ utilities/fixleftrightchanges/README.md | 68 ++++ .../fixleftrightchanges.py | 366 ++++++++++++++++++ 5 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 tests/testMcfLayout/test_reposition_mcf_layout.py create mode 100644 utilities/fixleftrightchanges/README.md create mode 100644 utilities/fixleftrightchanges/fixleftrightchanges.py diff --git a/README.md b/README.md index c6bbb546..e2847f3e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ more detail for users, contributors, and coding assistants. | Set up and test Linux using WSL | [Linux/WSL guide](documents/TestingOnWSL.md) | | Build or install a standalone executable | [Build and Windows integration guide](documents/build_a_compiled_version.md) | | Work with tests and approved PDFs | [Test workflow and cleanup](tests/README.md), and [compare-pdf interface](tests/compare-pdf/README.md) | +| Reposition layouts after changing album page sides | [MCF/MCFX page layout utility](utilities/fixleftrightchanges/README.md) | | Understand or author clip art and passepartout resources | [CLP file format and authoring notes](clipart/CLP_FILE_FORMAT.md) | | Consult superseded setups and investigations | [Historical documentation](documents/history/README.md) | @@ -222,6 +223,13 @@ shared operating-system font folders (for example ``C:\\Windows\\Fonts``). This is disabled by default because the available fonts differ between machines. +**Windows executable exception:** when the executable converts an album from +the Explorer context menu, automatic mode enables shared system-font discovery +even if an album or program ``cewe2pdf.ini`` sets ``loadSystemFonts = False``. +See the [Windows Explorer guide](documents/build_a_compiled_version.md#windows-explorer-installation-and-use). +This setting is separate from per-user fonts: those are still searched unless +the ``IGNORELOCALFONTS`` environment variable is set. + To find a potential ``additional_fonts.txt`` the code searches, in order, the album directory, the current directory and the location of the program itself; it uses only the **first** such file found. For normal use (i.e. actually creating a pdf album, rather than testing the code) the most reasonable strategy is to place an ``additional_fonts.txt`` file with the album file, out of the way of future updates to the program repository. The repository supplies a commented ``additional_fonts.example.txt`` which you can copy and adapt. It is deliberately not called ``additional_fonts.txt``, so it is never used as implicit configuration. diff --git a/documents/build_a_compiled_version.md b/documents/build_a_compiled_version.md index 3c21e885..4281707f 100644 --- a/documents/build_a_compiled_version.md +++ b/documents/build_a_compiled_version.md @@ -68,8 +68,11 @@ Administrator rights are not required. The editor remains the normal double-clic application for album files. Right-click an album and select the command. Automatic mode discovers the CEWE -installation from Windows file associations where possible and enables system fonts. -A valid explicit configuration still wins. The resulting PDF and a diagnostic +installation from Windows file associations where possible and enables shared +Windows system-font discovery. This deliberately overrides +`loadSystemFonts = False` in either INI file. An explicit, valid `cewe_folder` +still takes precedence over automatic CEWE-folder discovery. Per-user fonts are +also searched unless `IGNORELOCALFONTS` is set. The resulting PDF and a diagnostic `.mcf.log` or `.mcfx.log` are written beside the input. The log is overwritten each run. diff --git a/tests/testMcfLayout/test_reposition_mcf_layout.py b/tests/testMcfLayout/test_reposition_mcf_layout.py new file mode 100644 index 00000000..3c3765d4 --- /dev/null +++ b/tests/testMcfLayout/test_reposition_mcf_layout.py @@ -0,0 +1,179 @@ +"""Focused tests for page-side layout correction.""" + +import tempfile +import sqlite3 +import unittest +from pathlib import Path + +from lxml import etree + +from utilities.fixleftrightchanges.fixleftrightchanges import ( + parse_page_map, + reposition_album, + reposition_mcf, +) + + +def _write_mcf(path: Path, right_page_areas: str) -> None: + path.write_text( + f''' + + + + + + {right_page_areas} + +''', + encoding='UTF-8') + + +class TestParsePageMap(unittest.TestCase): + def test_expands_single_pages_and_ranges(self): + self.assertEqual( + parse_page_map('1-2:1-2, 3-27:4-28'), + [(1, 1), (2, 2)] + list(zip(range(3, 28), range(4, 29)))) + + def test_rejects_unequal_ranges_and_repeated_pages(self): + with self.assertRaisesRegex(ValueError, 'equal lengths'): + parse_page_map('1-3:2-3') + with self.assertRaisesRegex(ValueError, 'only once'): + parse_page_map('1-2:1-2,2:3') + + +class TestRepositionMcf(unittest.TestCase): + def test_moves_all_objects_to_preserve_inner_photo_gap(self): + right_page = ''' + + + + + ''' + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'edited.mcf' + _write_mcf(source, right_page) + + reports, output = reposition_mcf(source, [(2, 3)]) + + self.assertEqual(reports[0]['status'], 'moved') + self.assertEqual(reports[0]['old_anchor'], 'old-inner.jpg') + self.assertEqual(reports[0]['new_anchor'], 'new-inner.jpg') + self.assertEqual(reports[0]['margin_gap'], 200) + self.assertEqual(reports[0]['horizontal_shift'], 120) + self.assertEqual(output.name, 'edited.repositioned.mcf') + + root = etree.parse(str(output)).getroot() + positions = [ + area.find('position').get('left') + for area in root.findall('./page/area')] + self.assertEqual(positions, ['400', '1200', '1420', '1820']) + + def test_skips_pages_without_photos(self): + right_page = ''' + + ''' + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'edited.mcf' + _write_mcf(source, right_page) + + reports, output = reposition_mcf( + source, [(2, 3)], dry_run=True) + + self.assertIsNone(output) + self.assertEqual(reports[0]['status'], 'no photo anchor') + + def test_moves_in_the_opposite_direction_for_a_new_left_page(self): + left_page = ''' + + + ''' + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'edited.mcf' + _write_mcf(source, left_page) + + reports, output = reposition_mcf(source, [(3, 2)]) + + self.assertEqual(reports[0]['old_anchor'], 'old-inner.jpg') + self.assertEqual(reports[0]['new_anchor'], 'left.jpg') + self.assertEqual(reports[0]['margin_gap'], 200) + self.assertEqual(reports[0]['horizontal_shift'], 300) + root = etree.parse(str(output)).getroot() + positions = [ + area.find('position').get('left') + for area in root.findall('./page/area')] + self.assertEqual(positions, ['700', '500', '900']) + + def test_pages_that_keep_their_side_are_not_changed(self): + right_page = ''' + + ''' + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / 'edited.mcf' + _write_mcf(source, right_page) + + reports, _ = reposition_mcf(source, [(3, 5)], dry_run=True) + + self.assertEqual(reports, []) + + +class TestRepositionMcfx(unittest.TestCase): + def test_repositions_embedded_mcf_and_preserves_other_archive_files(self): + right_page = ''' + + + + + ''' + with tempfile.TemporaryDirectory() as directory: + workdir = Path(directory) + source = workdir / 'album.mcfx' + output = workdir / 'album.repositioned.mcfx' + mcf_bytes = _synthetic_mcf(right_page).encode('UTF-8') + connection = sqlite3.connect(source) + try: + connection.execute( + 'CREATE TABLE Files ' + '(Filename TEXT PRIMARY KEY, Data BLOB, LastModified INTEGER)') + connection.executemany( + 'INSERT INTO Files VALUES (?, ?, ?)', + [('data.mcf', mcf_bytes, 1), + ('new-inner.jpg', b'photo data', 1)]) + connection.commit() + finally: + connection.close() + original_bytes = source.read_bytes() + + reports, result = reposition_album(source, [(2, 3)], output) + + self.assertEqual(result, output) + self.assertEqual(reports[0]['horizontal_shift'], 120) + self.assertEqual(source.read_bytes(), original_bytes) + connection = sqlite3.connect(output) + try: + rows = dict(connection.execute( + 'SELECT Filename, Data FROM Files')) + finally: + connection.close() + self.assertEqual(rows['new-inner.jpg'], b'photo data') + extracted_mcf = etree.fromstring(rows['data.mcf']) + shifted_left = extracted_mcf.find('./page/area/position').get('left') + self.assertEqual(shifted_left, '400') + self.assertEqual( + extracted_mcf.findall('./page/area/position')[1].get('left'), + '1200') + + +def _synthetic_mcf(right_page_areas: str) -> str: + return f''' + + + + + + {right_page_areas} + +''' + + +if __name__ == '__main__': + unittest.main() diff --git a/utilities/fixleftrightchanges/README.md b/utilities/fixleftrightchanges/README.md new file mode 100644 index 00000000..aa3bd483 --- /dev/null +++ b/utilities/fixleftrightchanges/README.md @@ -0,0 +1,68 @@ +# MCF page layout repositioning + +`fixleftrightchanges.py` corrects horizontal layout after pages have been +inserted, deleted, or rearranged in the CEWE editor. It reads the edited MCF +or MCFX and an old-to-new map of the pages that still correspond. The old +album is not needed; the map tells the utility which pages have changed from +left to right or right to left. + +## Page map + +Page numbers are one-based editor page numbers. Each comma-separated item maps +old page numbers to new page numbers with `:`. A single page or an inclusive +range can appear on either side. Ranges must have equal lengths and page +numbers cannot repeat. For example: + +```text +1-2:1-2,3-8:4-9 +``` + +This says pages 1 and 2 stayed in place, while old pages 3 through 8 became +new pages 4 through 9. Pages omitted from the map were inserted or deleted. +Only mapped pages that changed side are repositioned. CEWE album page 1 is on +the right; odd pages are on the right and even pages are on the left. + +## Anchor and movement rule + +The utility considers photo areas and all other positioned areas on the +physical page. It identifies the photo nearest the outer edge of that page: +after a side change, this was the photo nearest the old inner edge. Its gap to +the physical page edge records the former inner-margin spacing. The photo at +the opposite edge of the page's content becomes the new inner anchor. One +horizontal translation puts that photo at the same gap from the new inner +edge. Every positioned area on that page receives the same translation, so +relative placement, text, and photo arrangement stay intact. No scaling, +mirroring, or vertical movement is applied. + +Pages without photo areas are skipped. The utility changes only pages whose +old and new page numbers have different parity. It does not infer a mapping or +move areas on unmapped pages. Page backgrounds stored separately from page +areas are not translated. + +The MCF stores spread-wide coordinates. Each area is assigned to a physical +page by the center of its rotated horizontal bounds. The measured gap is to +the physical page edge; no separate safe-margin guide is encoded in the MCF. + +## Run + +From the repository root: + +```text +python utilities/fixleftrightchanges/fixleftrightchanges.py --page-map "1-2:1-2,3-8:4-9" --dry-run edited.mcf +``` + +Review the reported anchor photos and offsets. To write the corrected MCF, +omit `--dry-run`: + +```text +python utilities/fixleftrightchanges/fixleftrightchanges.py --page-map "1-2:1-2,3-8:4-9" edited.mcf +``` + +The output has the same format as the input. By default, this creates +`edited.repositioned.mcf` or `edited.repositioned.mcfx` beside the input and +never overwrites an existing file. For MCFX input, the tool uses the project's +`unpackMcfx()` reader and replaces only the embedded MCF in a copy of the +archive, preserving its other files. Use `--output corrected.mcf` or +`--output corrected.mcfx` to choose another destination with the matching +extension. Open the result in CEWE and inspect the changed spreads before +continuing to edit the album. diff --git a/utilities/fixleftrightchanges/fixleftrightchanges.py b/utilities/fixleftrightchanges/fixleftrightchanges.py new file mode 100644 index 00000000..568645b4 --- /dev/null +++ b/utilities/fixleftrightchanges/fixleftrightchanges.py @@ -0,0 +1,366 @@ +"""Reposition page layouts after CEWE pages change spread sides.""" + +import argparse +from importlib import import_module +import math +from pathlib import Path +import shutil +import sqlite3 +import sys +import tempfile +import time + +from lxml import etree + + +def parse_page_map(specification: str) -> list[tuple[int, int]]: + """Expand comma-separated one-based old:new page pairs and ranges.""" + page_pairs = [] + for segment in specification.split(','): + sides = segment.strip().split(':') + if len(sides) != 2: + raise ValueError(f'Invalid page map segment: {segment!r}') + old_pages = _parse_page_range(sides[0]) + new_pages = _parse_page_range(sides[1]) + if len(old_pages) != len(new_pages): + raise ValueError( + f'Page map ranges must have equal lengths: {segment!r}') + page_pairs.extend(zip(old_pages, new_pages)) + + if not page_pairs: + raise ValueError('Page map must contain at least one pair') + if (len({pair[0] for pair in page_pairs}) != len(page_pairs) + or len({pair[1] for pair in page_pairs}) != len(page_pairs)): + raise ValueError('Each page may appear only once in a page map') + return page_pairs + + +def _parse_page_range(value: str) -> range: + parts = value.strip().split('-', maxsplit=1) + try: + start = int(parts[0]) + end = int(parts[1]) if len(parts) == 2 else start + except ValueError as error: + raise ValueError(f'Invalid page range: {value!r}') from error + if start < 1 or end < start: + raise ValueError(f'Invalid one-based page range: {value!r}') + return range(start, end + 1) + + +def reposition_mcf(input_path: Path, page_map: list[tuple[int, int]], + output_path: Path | None = None, dry_run: bool = False): + """Translate layouts on pages whose left/right side changed. + + ``page_map`` maps old editor page numbers to the corresponding page numbers + in the already-edited MCF. Source MCF content is not required. + """ + tree = etree.parse(str(input_path)) + root = tree.getroot() + _validate_album(root) + changed_pages = [ + (old_page, new_page) + for old_page, new_page in page_map + if old_page % 2 != new_page % 2] + reports = [] + for old_page, new_page in changed_pages: + report = _reposition_page(root, old_page, new_page) + reports.append(report) + + if dry_run: + return reports, None + + if output_path is None: + output_path = input_path.with_name( + f'{input_path.stem}.repositioned{input_path.suffix}') + if output_path.resolve() == input_path.resolve(): + raise ValueError('Output path must not overwrite the input MCF') + if output_path.exists(): + raise FileExistsError(f'Output file already exists: {output_path}') + + tree.write( + str(output_path), + encoding='UTF-8', + xml_declaration=True, + pretty_print=True) + return reports, output_path + + +def reposition_album(input_path: Path, page_map: list[tuple[int, int]], + output_path: Path | None = None, dry_run: bool = False): + """Reposition an MCF or MCFX album without modifying the input file.""" + input_path = Path(input_path) + suffix = input_path.suffix.casefold() + if suffix not in ('.mcf', '.mcfx'): + raise ValueError('Input must be an .mcf or .mcfx album file') + + if output_path is None: + output_path = input_path.with_name( + f'{input_path.stem}.repositioned{input_path.suffix}') + else: + output_path = Path(output_path) + if output_path.suffix.casefold() != suffix: + raise ValueError(f'Output file must also have the {suffix} extension') + if output_path.resolve() == input_path.resolve(): + raise ValueError('Output path must not overwrite the input album') + if not dry_run and output_path.exists(): + raise FileExistsError(f'Output file already exists: {output_path}') + + if suffix == '.mcf': + return reposition_mcf(input_path, page_map, output_path, dry_run) + + with tempfile.TemporaryDirectory(prefix='fixleftrightchanges-') as temporary_path: + extracted_directory = Path(temporary_path) / 'extracted' + extracted_directory.mkdir() + _, extracted_mcf = _unpack_mcfx(input_path, extracted_directory) + if dry_run: + return reposition_mcf(extracted_mcf, page_map, dry_run=True) + + modified_mcf = extracted_directory / 'repositioned.mcf' + reports, _ = reposition_mcf( + extracted_mcf, page_map, output_path=modified_mcf) + _replace_mcfx_mcf( + input_path, output_path, extracted_mcf.name, modified_mcf) + return reports, output_path + + +def _unpack_mcfx(input_path: Path, extracted_directory: Path): + """Call the normal MCFX reader when the script is launched by file path.""" + project_root = Path(__file__).resolve().parents[2] + if str(project_root) not in sys.path: + # A nested script's directory, not the current directory, is sys.path[0]. + sys.path.insert(0, str(project_root)) + return import_module('mcfx').unpackMcfx( + input_path.resolve(), extracted_directory) + + +def _replace_mcfx_mcf(input_path: Path, output_path: Path, + mcf_filename: str, modified_mcf: Path): + """Copy an MCFX and replace only its embedded MCF database row.""" + shutil.copy2(input_path, output_path) + connection = None + try: + connection = sqlite3.connect(output_path) + cursor = connection.execute( + 'UPDATE Files SET Data = ?, LastModified = ? WHERE Filename = ?', + (modified_mcf.read_bytes(), time.time_ns() // 1_000_000, + mcf_filename)) + if cursor.rowcount != 1: + raise ValueError( + 'Could not find exactly one embedded MCF row in the MCFX') + connection.commit() + except (OSError, sqlite3.Error, ValueError): + if connection is not None: + connection.close() + connection = None + output_path.unlink(missing_ok=True) + raise + finally: + if connection is not None: + connection.close() + + +def _validate_album(root): + product_name = root.get('productname', '').upper() + if not product_name.startswith('ALB'): + raise ValueError( + 'This utility currently supports CEWE album MCF files only') + if not root.findall("./page[@type='normalpage']"): + raise ValueError('The MCF has no normal album pages') + + +# pylint: disable=too-many-locals +# Keep the geometry intermediates named here so reported anchor bounds and the +# applied translation use the same explicit values. +def _reposition_page(root, old_page: int, new_page: int) -> dict: + page_element, page_origin, page_width = _get_page_record(root, new_page) + areas = _areas_on_physical_page(page_element, page_origin, page_width) + if not areas: + return { + 'old_page': old_page, + 'new_page': new_page, + 'status': 'no areas'} + + page_left = page_origin + page_right = page_origin + page_width + all_bounds = [bounds for _, bounds in areas] + content_left = min(bounds[0] for bounds in all_bounds) + content_right = max(bounds[1] for bounds in all_bounds) + photos = [ + (area, bounds) + for area, bounds in areas + if area.get('areatype', '').casefold() in ('imagearea', 'imagebackgroundarea') + and (area.find('image') is not None or area.find('imagebackground') is not None)] + if not photos: + return { + 'old_page': old_page, + 'new_page': new_page, + 'status': 'no photo anchor'} + + old_anchor, new_anchor, old_margin_gap, displacement = ( + _anchor_translation(photos, new_page, page_left, page_right)) + + for area, _ in areas: + position = area.find('position') + left = _read_number(position.get('left')) + position.set('left', _format_number(left + displacement)) + + moved_left = content_left + displacement + moved_right = content_right + displacement + return { + 'old_page': old_page, + 'new_page': new_page, + 'status': ( + 'moved' + if not math.isclose(displacement, 0, abs_tol=0.01) + else 'already aligned'), + 'old_anchor': _photo_name(old_anchor[0]), + 'new_anchor': _photo_name(new_anchor[0]), + 'margin_gap': old_margin_gap, + 'horizontal_shift': displacement, + 'content_bounds_after': (moved_left, moved_right), + 'page_bounds': (page_left, page_right), + } + + +def _get_page_record(root, page_number: int): + if page_number == 1: + opening_page = root.find( + "./page[@pagenr='0'][@type='emptypage'][area]") + if opening_page is None: + raise ValueError('Cannot find the opening page 1 area record') + record = opening_page + right_side = True + else: + record_number = page_number if page_number % 2 == 0 else page_number - 1 + record = root.find( + f"./page[@pagenr='{record_number}'][@type='normalpage']") + if record is None: + raise ValueError( + f'Cannot find the spread record for new page {page_number}') + right_side = page_number % 2 == 1 + + bundle_size = record.find('bundlesize') + if bundle_size is None: + raise ValueError(f'Page {page_number} has no bundle size') + spread_width = _read_number(bundle_size.get('width')) + if spread_width <= 0 or spread_width % 2: + raise ValueError( + f'Page {page_number} has an invalid double-page bundle width') + page_width = spread_width / 2 + page_origin = page_width if right_side else 0.0 + return record, page_origin, page_width + + +def _anchor_translation(photos, new_page, page_left, page_right): + """Return outer/inner photo anchors and the required horizontal shift.""" + # Once a page changes sides, its old inner edge becomes its outer edge. + # The outermost photo preserves the old photo-to-margin gap; the photo at + # the opposite edge becomes the new inner-margin anchor. + new_page_is_right = new_page % 2 == 1 + if new_page_is_right: + old_anchor = max(photos, key=lambda item: item[1][1]) + new_anchor = min(photos, key=lambda item: item[1][0]) + margin_gap = page_right - old_anchor[1][1] + desired_new_edge = page_left + margin_gap + displacement = desired_new_edge - new_anchor[1][0] + else: + old_anchor = min(photos, key=lambda item: item[1][0]) + new_anchor = max(photos, key=lambda item: item[1][1]) + margin_gap = old_anchor[1][0] - page_left + desired_new_edge = page_right - margin_gap + displacement = desired_new_edge - new_anchor[1][1] + return old_anchor, new_anchor, margin_gap, displacement + + +def _areas_on_physical_page(page_element, page_origin, page_width): + areas = [] + page_left = page_origin + page_right = page_origin + page_width + for area in page_element.findall('area'): + position = area.find('position') + if position is None: + continue + bounds = _area_horizontal_bounds(position) + centre = (bounds[0] + bounds[1]) / 2 + if page_left <= centre <= page_right: + areas.append((area, bounds)) + return areas + + +def _area_horizontal_bounds(position): + left = _read_number(position.get('left')) + width = _read_number(position.get('width')) + height = _read_number(position.get('height')) + rotation = math.radians(_read_number(position.get('rotation', '0'))) + rotated_width = abs(width * math.cos(rotation)) + abs(height * math.sin(rotation)) + centre = left + width / 2 + return centre - rotated_width / 2, centre + rotated_width / 2 + + +def _read_number(value: str | None) -> float: + if value is None: + raise ValueError('Missing numeric MCF position') + return float(value.replace(',', '.')) + + +def _format_number(value: float) -> str: + if math.isclose(value, round(value), abs_tol=1e-9): + return str(round(value)) + return f'{value:.6f}'.rstrip('0').rstrip('.') + + +def _photo_name(area) -> str: + image = area.find('image') + if image is None: + image = area.find('imagebackground') + return Path(image.get('filename', '(unnamed photo)')).name + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description='Reposition layouts on CEWE album pages moved to the other spread side.') + parser.add_argument( + 'album', type=Path, + help='Edited MCF or MCFX after page insertion, deletion or reorder') + parser.add_argument( + '--page-map', required=True, + help='One-based old:new editor-page pairs or equal-length ranges, e.g. 1-25:2-26') + parser.add_argument( + '--output', type=Path, + help='Output album (default: .repositioned.); input is never overwritten') + parser.add_argument( + '--dry-run', action='store_true', + help='Report anchor photos and horizontal offsets without writing an MCF') + args = parser.parse_args(argv) + + try: + page_map = parse_page_map(args.page_map) + reports, output_path = reposition_album( + args.album, page_map, args.output, dry_run=args.dry_run) + except (OSError, ValueError, sqlite3.Error, etree.XMLSyntaxError) as error: + parser.error(str(error)) + + changed_pages = 0 + for report in reports: + status = report['status'] + if status == 'moved': + changed_pages += 1 + print( + f"Old page {report['old_page']} -> new page {report['new_page']}: " + f"{report['old_anchor']} to {report['new_anchor']}, " + f"{report['horizontal_shift']:.2f} MCF units horizontally " + f"(old margin gap {report['margin_gap']:.2f}).") + else: + print( + f"Old page {report['old_page']} -> new page {report['new_page']}: " + f"{status}; no movement applied.") + + if args.dry_run: + print(f'Dry run: {changed_pages} page(s) would move.') + else: + print(f'Wrote {output_path}; {changed_pages} page(s) moved.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) From 537eeeadfa8c4287bc29ad84694d9ff0655f0b2e Mon Sep 17 00:00:00 2001 From: Pete Story Date: Fri, 23 Oct 2026 19:37:47 +0200 Subject: [PATCH 3/4] Fix a crash in page number placement setting Abandon fixrighttoleft, introduce contenttocenter utility --- README.md | 2 +- cewe2pdf.pyproj | 4 + pageNumbering.py | 5 +- utilities/contenttocenter/README.md | 55 +++++ .../contenttocenter.py} | 211 +++++++----------- utilities/fixleftrightchanges/README.md | 68 ------ 6 files changed, 142 insertions(+), 203 deletions(-) create mode 100644 utilities/contenttocenter/README.md rename utilities/{fixleftrightchanges/fixleftrightchanges.py => contenttocenter/contenttocenter.py} (54%) delete mode 100644 utilities/fixleftrightchanges/README.md diff --git a/README.md b/README.md index e2847f3e..a9ab8276 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ more detail for users, contributors, and coding assistants. | Set up and test Linux using WSL | [Linux/WSL guide](documents/TestingOnWSL.md) | | Build or install a standalone executable | [Build and Windows integration guide](documents/build_a_compiled_version.md) | | Work with tests and approved PDFs | [Test workflow and cleanup](tests/README.md), and [compare-pdf interface](tests/compare-pdf/README.md) | -| Reposition layouts after changing album page sides | [MCF/MCFX page layout utility](utilities/fixleftrightchanges/README.md) | +| Center selected album page content | [MCF/MCFX content centering utility](utilities/contenttocenter/README.md) | | Understand or author clip art and passepartout resources | [CLP file format and authoring notes](clipart/CLP_FILE_FORMAT.md) | | Consult superseded setups and investigations | [Historical documentation](documents/history/README.md) | diff --git a/cewe2pdf.pyproj b/cewe2pdf.pyproj index 70d451e2..f28c3f51 100644 --- a/cewe2pdf.pyproj +++ b/cewe2pdf.pyproj @@ -378,6 +378,7 @@ + @@ -496,6 +497,7 @@ + @@ -662,6 +664,8 @@ + + diff --git a/pageNumbering.py b/pageNumbering.py index b64651b8..ce9a8a17 100644 --- a/pageNumbering.py +++ b/pageNumbering.py @@ -55,6 +55,7 @@ def ToEnum(value: str): class PageNumberingInfo: def __init__(self, pageNumberElement, pdf, availableFonts, state: ConversionState): """ + Constructor that initializes the page numbering info from the given lxml element. """ # Extract relevant attributes or sub-elements from lxml_element @@ -63,8 +64,8 @@ def __init__(self, pageNumberElement, pdf, availableFonts, state: ConversionStat logging.error(f"Unrecognised pagenumbering position value {self.position}, reset to 1") self.position = 1 self.format = PageNumberFormat.ToEnum(pageNumberElement.get('format','0')) - self.horizontalMargin = int(pageNumberElement.get('margin','50')) * mcf2rl # * 0.1 mm - self.verticalMargin = int(pageNumberElement.get('verticalMargin','50')) * mcf2rl # * 0.1 mm + self.horizontalMargin = float(pageNumberElement.get('margin','50')) * mcf2rl # * 0.1 mm + self.verticalMargin = float(pageNumberElement.get('verticalMargin','50')) * mcf2rl # * 0.1 mm fontfamily = pageNumberElement.get('fontfamily','Liberation Sans') self.fontfamily = getAvailableFont(fontfamily, pdf, availableFonts, state) self.fontsize = int(pageNumberElement.get('fontsize','12')) diff --git a/utilities/contenttocenter/README.md b/utilities/contenttocenter/README.md new file mode 100644 index 00000000..7a39902d --- /dev/null +++ b/utilities/contenttocenter/README.md @@ -0,0 +1,55 @@ +# Centering album page content + +`contenttocenter.py` adjusts the horizontal position of page content in a CEWE +album. It reads the edited MCF or MCFX and an explicit list of pages to center; +it does not need the original album or a record of how pages changed. + +## Page selection + +Page numbers are one-based editor page numbers. Use comma-separated page +numbers or inclusive ranges. Pages cannot repeat. For example: + +```text +2-5,8,10-65 +``` + +This selects pages 2 through 5, page 8, and pages 10 through 65 in the edited +album. Only selected pages are adjusted. CEWE album page 1 is on the right; +odd pages are on the right and even pages are on the left. + +## Centering rule + +For each selected physical page, the tool finds the horizontal bounding box of +all positioned areas, including rotated areas. It shifts every area on that +page by the same amount so the center of that bounding box matches the physical +page center. This preserves relative positions between photos, text, and other +elements. It does not scale, mirror, or move elements vertically. Pages with no +positioned areas are skipped; pages do not need to contain photos. + +MCF positions use spread-wide coordinates. Areas are assigned to a physical +page by the center of their rotated horizontal bounds. Page backgrounds stored +separately from page areas are not translated. + +## Run + +From the repository root: + +```text +python utilities/contenttocenter/contenttocenter.py --pages 2-65 --dry-run edited.mcf +``` + +Review the reported shifts and centered bounds. To write the corrected MCF, +omit `--dry-run`: + +```text +python utilities/contenttocenter/contenttocenter.py --pages 2-65 edited.mcf +``` + +The output has the same format as the input. By default, this creates +`edited.centered.mcf` or `edited.centered.mcfx` beside the input and never +overwrites an existing file. For MCFX input, the tool uses the project's +`unpackMcfx()` reader and replaces only the embedded MCF in a copy of the +archive, preserving its other files. Use `--output corrected.mcf` or +`--output corrected.mcfx` to choose another destination with the matching +extension. Open the result in CEWE and inspect the changed spreads before +continuing to edit the album. diff --git a/utilities/fixleftrightchanges/fixleftrightchanges.py b/utilities/contenttocenter/contenttocenter.py similarity index 54% rename from utilities/fixleftrightchanges/fixleftrightchanges.py rename to utilities/contenttocenter/contenttocenter.py index 568645b4..64b09d9d 100644 --- a/utilities/fixleftrightchanges/fixleftrightchanges.py +++ b/utilities/contenttocenter/contenttocenter.py @@ -1,4 +1,4 @@ -"""Reposition page layouts after CEWE pages change spread sides.""" +"""Center selected page content in CEWE albums.""" import argparse from importlib import import_module @@ -13,65 +13,53 @@ from lxml import etree -def parse_page_map(specification: str) -> list[tuple[int, int]]: - """Expand comma-separated one-based old:new page pairs and ranges.""" - page_pairs = [] +def parse_pages(specification: str) -> list[int]: + """Expand comma-separated one-based page numbers and inclusive ranges.""" + pages = [] for segment in specification.split(','): - sides = segment.strip().split(':') - if len(sides) != 2: - raise ValueError(f'Invalid page map segment: {segment!r}') - old_pages = _parse_page_range(sides[0]) - new_pages = _parse_page_range(sides[1]) - if len(old_pages) != len(new_pages): - raise ValueError( - f'Page map ranges must have equal lengths: {segment!r}') - page_pairs.extend(zip(old_pages, new_pages)) - - if not page_pairs: - raise ValueError('Page map must contain at least one pair') - if (len({pair[0] for pair in page_pairs}) != len(page_pairs) - or len({pair[1] for pair in page_pairs}) != len(page_pairs)): - raise ValueError('Each page may appear only once in a page map') - return page_pairs - - -def _parse_page_range(value: str) -> range: - parts = value.strip().split('-', maxsplit=1) - try: - start = int(parts[0]) - end = int(parts[1]) if len(parts) == 2 else start - except ValueError as error: - raise ValueError(f'Invalid page range: {value!r}') from error - if start < 1 or end < start: - raise ValueError(f'Invalid one-based page range: {value!r}') - return range(start, end + 1) - - -def reposition_mcf(input_path: Path, page_map: list[tuple[int, int]], - output_path: Path | None = None, dry_run: bool = False): - """Translate layouts on pages whose left/right side changed. - - ``page_map`` maps old editor page numbers to the corresponding page numbers - in the already-edited MCF. Source MCF content is not required. - """ + parts = segment.strip().split('-', maxsplit=1) + try: + start = int(parts[0]) + end = int(parts[1]) if len(parts) == 2 else start + except ValueError as error: + raise ValueError(f'Invalid page selection: {segment!r}') from error + if start < 1 or end < start: + raise ValueError(f'Invalid one-based page range: {segment!r}') + pages.extend(range(start, end + 1)) + + if not pages: + raise ValueError('Page selection must contain at least one page') + if len(set(pages)) != len(pages): + raise ValueError('Each page may appear only once in the selection') + return pages + + +def center_content_mcf(input_path: Path, pages: list[int], + output_path: Path | None = None, dry_run: bool = False): + """Center content on the selected pages of an edited MCF.""" tree = etree.parse(str(input_path)) root = tree.getroot() _validate_album(root) - changed_pages = [ - (old_page, new_page) - for old_page, new_page in page_map - if old_page % 2 != new_page % 2] - reports = [] - for old_page, new_page in changed_pages: - report = _reposition_page(root, old_page, new_page) - reports.append(report) + page_geometry = [] + for page_number in pages: + page_element, page_origin, page_width = _get_page_record( + root, page_number) + areas = _areas_on_physical_page( + page_element, page_origin, page_width) + page_geometry.append((page_number, page_origin, page_width, areas)) + + # Resolve each page's areas before moving any of them. Pages in the same + # spread share an MCF record, so a centered area could cross the gutter. + reports = [ + _center_page_content(page_number, page_origin, page_width, areas) + for page_number, page_origin, page_width, areas in page_geometry] if dry_run: return reports, None if output_path is None: output_path = input_path.with_name( - f'{input_path.stem}.repositioned{input_path.suffix}') + f'{input_path.stem}.centered{input_path.suffix}') if output_path.resolve() == input_path.resolve(): raise ValueError('Output path must not overwrite the input MCF') if output_path.exists(): @@ -85,9 +73,9 @@ def reposition_mcf(input_path: Path, page_map: list[tuple[int, int]], return reports, output_path -def reposition_album(input_path: Path, page_map: list[tuple[int, int]], - output_path: Path | None = None, dry_run: bool = False): - """Reposition an MCF or MCFX album without modifying the input file.""" +def center_content_album(input_path: Path, pages: list[int], + output_path: Path | None = None, dry_run: bool = False): + """Center content in an MCF or MCFX album without modifying the input.""" input_path = Path(input_path) suffix = input_path.suffix.casefold() if suffix not in ('.mcf', '.mcfx'): @@ -95,7 +83,7 @@ def reposition_album(input_path: Path, page_map: list[tuple[int, int]], if output_path is None: output_path = input_path.with_name( - f'{input_path.stem}.repositioned{input_path.suffix}') + f'{input_path.stem}.centered{input_path.suffix}') else: output_path = Path(output_path) if output_path.suffix.casefold() != suffix: @@ -106,18 +94,18 @@ def reposition_album(input_path: Path, page_map: list[tuple[int, int]], raise FileExistsError(f'Output file already exists: {output_path}') if suffix == '.mcf': - return reposition_mcf(input_path, page_map, output_path, dry_run) + return center_content_mcf(input_path, pages, output_path, dry_run) - with tempfile.TemporaryDirectory(prefix='fixleftrightchanges-') as temporary_path: + with tempfile.TemporaryDirectory(prefix='contenttocenter-') as temporary_path: extracted_directory = Path(temporary_path) / 'extracted' extracted_directory.mkdir() _, extracted_mcf = _unpack_mcfx(input_path, extracted_directory) if dry_run: - return reposition_mcf(extracted_mcf, page_map, dry_run=True) + return center_content_mcf(extracted_mcf, pages, dry_run=True) - modified_mcf = extracted_directory / 'repositioned.mcf' - reports, _ = reposition_mcf( - extracted_mcf, page_map, output_path=modified_mcf) + modified_mcf = extracted_directory / 'centered.mcf' + reports, _ = center_content_mcf( + extracted_mcf, pages, output_path=modified_mcf) _replace_mcfx_mcf( input_path, output_path, extracted_mcf.name, modified_mcf) return reports, output_path @@ -169,15 +157,13 @@ def _validate_album(root): # pylint: disable=too-many-locals -# Keep the geometry intermediates named here so reported anchor bounds and the -# applied translation use the same explicit values. -def _reposition_page(root, old_page: int, new_page: int) -> dict: - page_element, page_origin, page_width = _get_page_record(root, new_page) - areas = _areas_on_physical_page(page_element, page_origin, page_width) +# Keep geometry intermediates named so reported content bounds and the applied +# translation use the same explicit values. +def _center_page_content(page_number: int, page_origin: float, + page_width: float, areas: list) -> dict: if not areas: return { - 'old_page': old_page, - 'new_page': new_page, + 'page': page_number, 'status': 'no areas'} page_left = page_origin @@ -185,19 +171,8 @@ def _reposition_page(root, old_page: int, new_page: int) -> dict: all_bounds = [bounds for _, bounds in areas] content_left = min(bounds[0] for bounds in all_bounds) content_right = max(bounds[1] for bounds in all_bounds) - photos = [ - (area, bounds) - for area, bounds in areas - if area.get('areatype', '').casefold() in ('imagearea', 'imagebackgroundarea') - and (area.find('image') is not None or area.find('imagebackground') is not None)] - if not photos: - return { - 'old_page': old_page, - 'new_page': new_page, - 'status': 'no photo anchor'} - - old_anchor, new_anchor, old_margin_gap, displacement = ( - _anchor_translation(photos, new_page, page_left, page_right)) + displacement = ( + (page_left + page_right - content_left - content_right) / 2) for area, _ in areas: position = area.find('position') @@ -207,15 +182,11 @@ def _reposition_page(root, old_page: int, new_page: int) -> dict: moved_left = content_left + displacement moved_right = content_right + displacement return { - 'old_page': old_page, - 'new_page': new_page, + 'page': page_number, 'status': ( 'moved' if not math.isclose(displacement, 0, abs_tol=0.01) else 'already aligned'), - 'old_anchor': _photo_name(old_anchor[0]), - 'new_anchor': _photo_name(new_anchor[0]), - 'margin_gap': old_margin_gap, 'horizontal_shift': displacement, 'content_bounds_after': (moved_left, moved_right), 'page_bounds': (page_left, page_right), @@ -236,7 +207,7 @@ def _get_page_record(root, page_number: int): f"./page[@pagenr='{record_number}'][@type='normalpage']") if record is None: raise ValueError( - f'Cannot find the spread record for new page {page_number}') + f'Cannot find the spread record for page {page_number}') right_side = page_number % 2 == 1 bundle_size = record.find('bundlesize') @@ -251,27 +222,6 @@ def _get_page_record(root, page_number: int): return record, page_origin, page_width -def _anchor_translation(photos, new_page, page_left, page_right): - """Return outer/inner photo anchors and the required horizontal shift.""" - # Once a page changes sides, its old inner edge becomes its outer edge. - # The outermost photo preserves the old photo-to-margin gap; the photo at - # the opposite edge becomes the new inner-margin anchor. - new_page_is_right = new_page % 2 == 1 - if new_page_is_right: - old_anchor = max(photos, key=lambda item: item[1][1]) - new_anchor = min(photos, key=lambda item: item[1][0]) - margin_gap = page_right - old_anchor[1][1] - desired_new_edge = page_left + margin_gap - displacement = desired_new_edge - new_anchor[1][0] - else: - old_anchor = min(photos, key=lambda item: item[1][0]) - new_anchor = max(photos, key=lambda item: item[1][1]) - margin_gap = old_anchor[1][0] - page_left - desired_new_edge = page_right - margin_gap - displacement = desired_new_edge - new_anchor[1][1] - return old_anchor, new_anchor, margin_gap, displacement - - def _areas_on_physical_page(page_element, page_origin, page_width): areas = [] page_left = page_origin @@ -309,56 +259,53 @@ def _format_number(value: float) -> str: return f'{value:.6f}'.rstrip('0').rstrip('.') -def _photo_name(area) -> str: - image = area.find('image') - if image is None: - image = area.find('imagebackground') - return Path(image.get('filename', '(unnamed photo)')).name - - def main(argv=None) -> int: parser = argparse.ArgumentParser( - description='Reposition layouts on CEWE album pages moved to the other spread side.') + description='Center content on selected CEWE album pages.') parser.add_argument( 'album', type=Path, - help='Edited MCF or MCFX after page insertion, deletion or reorder') + help='Edited MCF or MCFX album') parser.add_argument( - '--page-map', required=True, - help='One-based old:new editor-page pairs or equal-length ranges, e.g. 1-25:2-26') + '--pages', required=True, + help='One-based pages to center, as comma-separated numbers or ranges, e.g. 2-65') parser.add_argument( '--output', type=Path, - help='Output album (default: .repositioned.); input is never overwritten') + help='Output album (default: .centered.); input is never overwritten') parser.add_argument( '--dry-run', action='store_true', - help='Report anchor photos and horizontal offsets without writing an MCF') + help='Report content bounds and horizontal offsets without writing an MCF') args = parser.parse_args(argv) try: - page_map = parse_page_map(args.page_map) - reports, output_path = reposition_album( - args.album, page_map, args.output, dry_run=args.dry_run) + pages = parse_pages(args.pages) + reports, output_path = center_content_album( + args.album, pages, args.output, dry_run=args.dry_run) except (OSError, ValueError, sqlite3.Error, etree.XMLSyntaxError) as error: parser.error(str(error)) - changed_pages = 0 + moved_pages = 0 for report in reports: status = report['status'] if status == 'moved': - changed_pages += 1 + moved_pages += 1 print( - f"Old page {report['old_page']} -> new page {report['new_page']}: " - f"{report['old_anchor']} to {report['new_anchor']}, " - f"{report['horizontal_shift']:.2f} MCF units horizontally " - f"(old margin gap {report['margin_gap']:.2f}).") + f"Page {report['page']}: " + f"content centered with {report['horizontal_shift']:.2f} " + 'MCF units horizontal shift; ' + f"content bounds {report['content_bounds_after']}.") else: print( - f"Old page {report['old_page']} -> new page {report['new_page']}: " + f"Page {report['page']}: " f"{status}; no movement applied.") if args.dry_run: - print(f'Dry run: {changed_pages} page(s) would move.') + print( + f'Dry run: {len(reports)} page(s) selected; ' + f'{moved_pages} would move.') else: - print(f'Wrote {output_path}; {changed_pages} page(s) moved.') + print( + f'Wrote {output_path}; {len(reports)} page(s) selected, ' + f'{moved_pages} moved.') return 0 diff --git a/utilities/fixleftrightchanges/README.md b/utilities/fixleftrightchanges/README.md deleted file mode 100644 index aa3bd483..00000000 --- a/utilities/fixleftrightchanges/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# MCF page layout repositioning - -`fixleftrightchanges.py` corrects horizontal layout after pages have been -inserted, deleted, or rearranged in the CEWE editor. It reads the edited MCF -or MCFX and an old-to-new map of the pages that still correspond. The old -album is not needed; the map tells the utility which pages have changed from -left to right or right to left. - -## Page map - -Page numbers are one-based editor page numbers. Each comma-separated item maps -old page numbers to new page numbers with `:`. A single page or an inclusive -range can appear on either side. Ranges must have equal lengths and page -numbers cannot repeat. For example: - -```text -1-2:1-2,3-8:4-9 -``` - -This says pages 1 and 2 stayed in place, while old pages 3 through 8 became -new pages 4 through 9. Pages omitted from the map were inserted or deleted. -Only mapped pages that changed side are repositioned. CEWE album page 1 is on -the right; odd pages are on the right and even pages are on the left. - -## Anchor and movement rule - -The utility considers photo areas and all other positioned areas on the -physical page. It identifies the photo nearest the outer edge of that page: -after a side change, this was the photo nearest the old inner edge. Its gap to -the physical page edge records the former inner-margin spacing. The photo at -the opposite edge of the page's content becomes the new inner anchor. One -horizontal translation puts that photo at the same gap from the new inner -edge. Every positioned area on that page receives the same translation, so -relative placement, text, and photo arrangement stay intact. No scaling, -mirroring, or vertical movement is applied. - -Pages without photo areas are skipped. The utility changes only pages whose -old and new page numbers have different parity. It does not infer a mapping or -move areas on unmapped pages. Page backgrounds stored separately from page -areas are not translated. - -The MCF stores spread-wide coordinates. Each area is assigned to a physical -page by the center of its rotated horizontal bounds. The measured gap is to -the physical page edge; no separate safe-margin guide is encoded in the MCF. - -## Run - -From the repository root: - -```text -python utilities/fixleftrightchanges/fixleftrightchanges.py --page-map "1-2:1-2,3-8:4-9" --dry-run edited.mcf -``` - -Review the reported anchor photos and offsets. To write the corrected MCF, -omit `--dry-run`: - -```text -python utilities/fixleftrightchanges/fixleftrightchanges.py --page-map "1-2:1-2,3-8:4-9" edited.mcf -``` - -The output has the same format as the input. By default, this creates -`edited.repositioned.mcf` or `edited.repositioned.mcfx` beside the input and -never overwrites an existing file. For MCFX input, the tool uses the project's -`unpackMcfx()` reader and replaces only the embedded MCF in a copy of the -archive, preserving its other files. Use `--output corrected.mcf` or -`--output corrected.mcfx` to choose another destination with the matching -extension. Open the result in CEWE and inspect the changed spreads before -continuing to edit the album. From 6dc9c5e2d65a357c73e68ecf5d0b0e2ba6f6416b Mon Sep 17 00:00:00 2001 From: Pete Story Date: Fri, 23 Oct 2026 19:51:10 +0200 Subject: [PATCH 4/4] Delete tests/testMcfLayout/test_reposition_mcf_layout.py --- .../test_reposition_mcf_layout.py | 179 ------------------ 1 file changed, 179 deletions(-) delete mode 100644 tests/testMcfLayout/test_reposition_mcf_layout.py diff --git a/tests/testMcfLayout/test_reposition_mcf_layout.py b/tests/testMcfLayout/test_reposition_mcf_layout.py deleted file mode 100644 index 3c3765d4..00000000 --- a/tests/testMcfLayout/test_reposition_mcf_layout.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Focused tests for page-side layout correction.""" - -import tempfile -import sqlite3 -import unittest -from pathlib import Path - -from lxml import etree - -from utilities.fixleftrightchanges.fixleftrightchanges import ( - parse_page_map, - reposition_album, - reposition_mcf, -) - - -def _write_mcf(path: Path, right_page_areas: str) -> None: - path.write_text( - f''' - - - - - - {right_page_areas} - -''', - encoding='UTF-8') - - -class TestParsePageMap(unittest.TestCase): - def test_expands_single_pages_and_ranges(self): - self.assertEqual( - parse_page_map('1-2:1-2, 3-27:4-28'), - [(1, 1), (2, 2)] + list(zip(range(3, 28), range(4, 29)))) - - def test_rejects_unequal_ranges_and_repeated_pages(self): - with self.assertRaisesRegex(ValueError, 'equal lengths'): - parse_page_map('1-3:2-3') - with self.assertRaisesRegex(ValueError, 'only once'): - parse_page_map('1-2:1-2,2:3') - - -class TestRepositionMcf(unittest.TestCase): - def test_moves_all_objects_to_preserve_inner_photo_gap(self): - right_page = ''' - - - - - ''' - with tempfile.TemporaryDirectory() as directory: - source = Path(directory) / 'edited.mcf' - _write_mcf(source, right_page) - - reports, output = reposition_mcf(source, [(2, 3)]) - - self.assertEqual(reports[0]['status'], 'moved') - self.assertEqual(reports[0]['old_anchor'], 'old-inner.jpg') - self.assertEqual(reports[0]['new_anchor'], 'new-inner.jpg') - self.assertEqual(reports[0]['margin_gap'], 200) - self.assertEqual(reports[0]['horizontal_shift'], 120) - self.assertEqual(output.name, 'edited.repositioned.mcf') - - root = etree.parse(str(output)).getroot() - positions = [ - area.find('position').get('left') - for area in root.findall('./page/area')] - self.assertEqual(positions, ['400', '1200', '1420', '1820']) - - def test_skips_pages_without_photos(self): - right_page = ''' - - ''' - with tempfile.TemporaryDirectory() as directory: - source = Path(directory) / 'edited.mcf' - _write_mcf(source, right_page) - - reports, output = reposition_mcf( - source, [(2, 3)], dry_run=True) - - self.assertIsNone(output) - self.assertEqual(reports[0]['status'], 'no photo anchor') - - def test_moves_in_the_opposite_direction_for_a_new_left_page(self): - left_page = ''' - - - ''' - with tempfile.TemporaryDirectory() as directory: - source = Path(directory) / 'edited.mcf' - _write_mcf(source, left_page) - - reports, output = reposition_mcf(source, [(3, 2)]) - - self.assertEqual(reports[0]['old_anchor'], 'old-inner.jpg') - self.assertEqual(reports[0]['new_anchor'], 'left.jpg') - self.assertEqual(reports[0]['margin_gap'], 200) - self.assertEqual(reports[0]['horizontal_shift'], 300) - root = etree.parse(str(output)).getroot() - positions = [ - area.find('position').get('left') - for area in root.findall('./page/area')] - self.assertEqual(positions, ['700', '500', '900']) - - def test_pages_that_keep_their_side_are_not_changed(self): - right_page = ''' - - ''' - with tempfile.TemporaryDirectory() as directory: - source = Path(directory) / 'edited.mcf' - _write_mcf(source, right_page) - - reports, _ = reposition_mcf(source, [(3, 5)], dry_run=True) - - self.assertEqual(reports, []) - - -class TestRepositionMcfx(unittest.TestCase): - def test_repositions_embedded_mcf_and_preserves_other_archive_files(self): - right_page = ''' - - - - - ''' - with tempfile.TemporaryDirectory() as directory: - workdir = Path(directory) - source = workdir / 'album.mcfx' - output = workdir / 'album.repositioned.mcfx' - mcf_bytes = _synthetic_mcf(right_page).encode('UTF-8') - connection = sqlite3.connect(source) - try: - connection.execute( - 'CREATE TABLE Files ' - '(Filename TEXT PRIMARY KEY, Data BLOB, LastModified INTEGER)') - connection.executemany( - 'INSERT INTO Files VALUES (?, ?, ?)', - [('data.mcf', mcf_bytes, 1), - ('new-inner.jpg', b'photo data', 1)]) - connection.commit() - finally: - connection.close() - original_bytes = source.read_bytes() - - reports, result = reposition_album(source, [(2, 3)], output) - - self.assertEqual(result, output) - self.assertEqual(reports[0]['horizontal_shift'], 120) - self.assertEqual(source.read_bytes(), original_bytes) - connection = sqlite3.connect(output) - try: - rows = dict(connection.execute( - 'SELECT Filename, Data FROM Files')) - finally: - connection.close() - self.assertEqual(rows['new-inner.jpg'], b'photo data') - extracted_mcf = etree.fromstring(rows['data.mcf']) - shifted_left = extracted_mcf.find('./page/area/position').get('left') - self.assertEqual(shifted_left, '400') - self.assertEqual( - extracted_mcf.findall('./page/area/position')[1].get('left'), - '1200') - - -def _synthetic_mcf(right_page_areas: str) -> str: - return f''' - - - - - - {right_page_areas} - -''' - - -if __name__ == '__main__': - unittest.main()