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
diff --git a/README.md b/README.md
index c6bbb546..a9ab8276 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) |
+| 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) |
@@ -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/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/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/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/contenttocenter/contenttocenter.py b/utilities/contenttocenter/contenttocenter.py
new file mode 100644
index 00000000..64b09d9d
--- /dev/null
+++ b/utilities/contenttocenter/contenttocenter.py
@@ -0,0 +1,313 @@
+"""Center selected page content in CEWE albums."""
+
+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_pages(specification: str) -> list[int]:
+ """Expand comma-separated one-based page numbers and inclusive ranges."""
+ pages = []
+ for segment in specification.split(','):
+ 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)
+ 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}.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():
+ 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 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'):
+ 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}.centered{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 center_content_mcf(input_path, pages, output_path, dry_run)
+
+ 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 center_content_mcf(extracted_mcf, pages, dry_run=True)
+
+ 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
+
+
+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 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 {
+ 'page': page_number,
+ '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)
+ displacement = (
+ (page_left + page_right - content_left - content_right) / 2)
+
+ 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 {
+ 'page': page_number,
+ 'status': (
+ 'moved'
+ if not math.isclose(displacement, 0, abs_tol=0.01)
+ else 'already aligned'),
+ '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 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 _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 main(argv=None) -> int:
+ parser = argparse.ArgumentParser(
+ description='Center content on selected CEWE album pages.')
+ parser.add_argument(
+ 'album', type=Path,
+ help='Edited MCF or MCFX album')
+ parser.add_argument(
+ '--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: .centered.); input is never overwritten')
+ parser.add_argument(
+ '--dry-run', action='store_true',
+ help='Report content bounds and horizontal offsets without writing an MCF')
+ args = parser.parse_args(argv)
+
+ try:
+ 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))
+
+ moved_pages = 0
+ for report in reports:
+ status = report['status']
+ if status == 'moved':
+ moved_pages += 1
+ print(
+ 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"Page {report['page']}: "
+ f"{status}; no movement applied.")
+
+ if args.dry_run:
+ print(
+ f'Dry run: {len(reports)} page(s) selected; '
+ f'{moved_pages} would move.')
+ else:
+ print(
+ f'Wrote {output_path}; {len(reports)} page(s) selected, '
+ f'{moved_pages} moved.')
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())