ebookerr-sdk 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/PKG-INFO +9 -3
  2. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/README.md +8 -2
  3. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/__init__.py +1 -1
  4. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/document.py +139 -17
  5. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/build.py +154 -117
  6. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/document.py +43 -0
  7. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/nav.py +11 -0
  8. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/ncx.py +14 -0
  9. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/text_convert.py +80 -21
  10. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/declarations.py +1 -1
  11. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk.egg-info/PKG-INFO +9 -3
  12. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/pyproject.toml +1 -1
  13. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/LICENSE +0 -0
  14. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/__init__.py +0 -0
  15. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/adoption.py +0 -0
  16. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/chapter_naming.py +0 -0
  17. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/chapter_number.py +0 -0
  18. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/dates.py +0 -0
  19. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/epub_hash.py +0 -0
  20. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/epub_metadata.py +0 -0
  21. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/ids.py +0 -0
  22. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/metadata.py +0 -0
  23. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/story_url.py +0 -0
  24. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/survivor.py +0 -0
  25. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/domain/text_encoding.py +0 -0
  26. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/__init__.py +0 -0
  27. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/availability.py +0 -0
  28. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/check.py +0 -0
  29. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/disposition.py +0 -0
  30. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/identity.py +0 -0
  31. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/paths.py +0 -0
  32. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/download/routing.py +0 -0
  33. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/__init__.py +0 -0
  34. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/archive.py +0 -0
  35. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/assets.py +0 -0
  36. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/builder.py +0 -0
  37. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/chapter_numbers.py +0 -0
  38. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/chapters.py +0 -0
  39. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/errors.py +0 -0
  40. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/fonts.py +0 -0
  41. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/opf.py +0 -0
  42. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/roles.py +0 -0
  43. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/safe_xml.py +0 -0
  44. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/serialize.py +0 -0
  45. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/text.py +0 -0
  46. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/titles.py +0 -0
  47. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/epub/xhtml.py +0 -0
  48. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/generation/__init__.py +0 -0
  49. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/generation/client.py +0 -0
  50. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/generation/output.py +0 -0
  51. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/generation/prompt_template.py +0 -0
  52. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/generation/support.py +0 -0
  53. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/host.py +0 -0
  54. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/__init__.py +0 -0
  55. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/__main__.py +0 -0
  56. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/build.py +0 -0
  57. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/changelog.py +0 -0
  58. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/check.py +0 -0
  59. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/cli.py +0 -0
  60. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/files.py +0 -0
  61. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/imports.py +0 -0
  62. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/index.py +0 -0
  63. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/pack/scaffold.py +0 -0
  64. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/__init__.py +0 -0
  65. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/anchor_types.py +0 -0
  66. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/anchoring.py +0 -0
  67. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/connection.py +0 -0
  68. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/link_attempt.py +0 -0
  69. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/link_refusal.py +0 -0
  70. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/restore_marker.py +0 -0
  71. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/scan_ledger.py +0 -0
  72. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/providers/urls.py +0 -0
  73. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/py.typed +0 -0
  74. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/readpos.py +0 -0
  75. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/registry_index.py +0 -0
  76. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/__init__.py +0 -0
  77. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/context.py +0 -0
  78. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/errors.py +0 -0
  79. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/glyphs.py +0 -0
  80. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/manifest.py +0 -0
  81. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/material_symbols.txt +0 -0
  82. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/protocols.py +0 -0
  83. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/settings.py +0 -0
  84. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/summary_template.py +0 -0
  85. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/types.py +0 -0
  86. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/spi/views.py +0 -0
  87. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/testing/__init__.py +0 -0
  88. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/testing/context.py +0 -0
  89. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/testing/views.py +0 -0
  90. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/testing/wire.py +0 -0
  91. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/__init__.py +0 -0
  92. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/external.py +0 -0
  93. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/model.py +0 -0
  94. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/rules_css.py +0 -0
  95. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/rules_html.py +0 -0
  96. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/rules_nav.py +0 -0
  97. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/rules_opf.py +0 -0
  98. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/rules_package.py +0 -0
  99. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/validate/validate.py +0 -0
  100. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk/wire.py +0 -0
  101. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk.egg-info/SOURCES.txt +0 -0
  102. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk.egg-info/dependency_links.txt +0 -0
  103. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk.egg-info/requires.txt +0 -0
  104. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/ebookerr_sdk.egg-info/top_level.txt +0 -0
  105. {ebookerr_sdk-3.2.0 → ebookerr_sdk-3.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebookerr-sdk
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits.
5
5
  Author: ArcCdr
6
6
  License-Expression: MIT
@@ -41,10 +41,10 @@ validation, library servers and text generation.
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- pip install "ebookerr-sdk==3.2.*"
44
+ pip install "ebookerr-sdk==3.3.*"
45
45
  ```
46
46
 
47
- The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.2.x speaks SPI 3.2. It needs
47
+ The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.3.x speaks SPI 3.3. It needs
48
48
  Python 3.11 or later.
49
49
 
50
50
  ## Start a plugin
@@ -66,6 +66,7 @@ or `catalog`. The guide explains what to write next and how to install the zip i
66
66
  | 2.24 | 3.0 | 3.0.x |
67
67
  | 2.24.1 | 3.1 | 3.1.x |
68
68
  | 2.24.7 | 3.2 | 3.2.x |
69
+ | 2.24.9 | 3.3 | 3.3.x |
69
70
 
70
71
  ebookerr loads a plugin when its `spi_version` has ebookerr's SPI MAJOR and a MINOR not above
71
72
  ebookerr's; otherwise it shows the plugin as incompatible and offers a compatible release.
@@ -93,6 +94,11 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
93
94
 
94
95
  ## Release notes
95
96
 
97
+ ### 3.3.0
98
+
99
+ SPI 3.3. The EPUB kit gains helpers for the EPUBs ebookerr writes; later sentences of this
100
+ section name each one. A plugin written for SPI 3.2 needs no change. `build_epub` writes its package through `ebookerr_sdk.epub.builder`: the NCX `dtb:uid` is the OPF identifier and the package carries its creation date, so a built book validates without findings. `EpubDocument.set_title` writes a new title into the package and every table of contents (`NcxDocument.set_doc_title`, `NavDocument.set_doc_title`), so a retitled book's OPF and NCX agree. `retitle_built_epub` gives a `build_epub` output a new title in its package, tables of contents and title page; `convert_stage` and `download_convert_stage` use it, so a converted book's file carries the title the book gets. `ebookerr_sdk.epub.text_convert.is_chapter_heading` names the prose chapter-heading rule. `convert_stage` titles an uploaded document after its own title, else its file name unless the name is a placeholder such as `Document1` or `Untitled`, else a first line that is no chapter heading. `Book.front_matter` holds the text above a document's first heading; `build_epub` puts it on the title page, and `parse_text_to_book` fills it instead of making that text a chapter. `parse_text_to_book` keeps each line of a text that has no blank line at all as its own paragraph. `convert_stage` and `download_convert_stage` pass a `user_facing` conversion error's message through unchanged and answer "The file could not be converted." for any other failure, so no library message or path reaches a user. `EpubDocument.drm_protected()` tells a DRM-protected book (a `rights.xml`, or an `encryption.xml` using anything but font obfuscation) from one whose fonts are only obfuscated.
101
+
96
102
  ### 3.2.0
97
103
 
98
104
  SPI 3.2. A story can name the multi-part work it is one part of: `StoryPatch.work` and
@@ -12,10 +12,10 @@ validation, library servers and text generation.
12
12
  ## Install
13
13
 
14
14
  ```bash
15
- pip install "ebookerr-sdk==3.2.*"
15
+ pip install "ebookerr-sdk==3.3.*"
16
16
  ```
17
17
 
18
- The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.2.x speaks SPI 3.2. It needs
18
+ The SDK's MAJOR.MINOR is the SPI version it speaks: `ebookerr-sdk` 3.3.x speaks SPI 3.3. It needs
19
19
  Python 3.11 or later.
20
20
 
21
21
  ## Start a plugin
@@ -37,6 +37,7 @@ or `catalog`. The guide explains what to write next and how to install the zip i
37
37
  | 2.24 | 3.0 | 3.0.x |
38
38
  | 2.24.1 | 3.1 | 3.1.x |
39
39
  | 2.24.7 | 3.2 | 3.2.x |
40
+ | 2.24.9 | 3.3 | 3.3.x |
40
41
 
41
42
  ebookerr loads a plugin when its `spi_version` has ebookerr's SPI MAJOR and a MINOR not above
42
43
  ebookerr's; otherwise it shows the plugin as incompatible and offers a compatible release.
@@ -64,6 +65,11 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
64
65
 
65
66
  ## Release notes
66
67
 
68
+ ### 3.3.0
69
+
70
+ SPI 3.3. The EPUB kit gains helpers for the EPUBs ebookerr writes; later sentences of this
71
+ section name each one. A plugin written for SPI 3.2 needs no change. `build_epub` writes its package through `ebookerr_sdk.epub.builder`: the NCX `dtb:uid` is the OPF identifier and the package carries its creation date, so a built book validates without findings. `EpubDocument.set_title` writes a new title into the package and every table of contents (`NcxDocument.set_doc_title`, `NavDocument.set_doc_title`), so a retitled book's OPF and NCX agree. `retitle_built_epub` gives a `build_epub` output a new title in its package, tables of contents and title page; `convert_stage` and `download_convert_stage` use it, so a converted book's file carries the title the book gets. `ebookerr_sdk.epub.text_convert.is_chapter_heading` names the prose chapter-heading rule. `convert_stage` titles an uploaded document after its own title, else its file name unless the name is a placeholder such as `Document1` or `Untitled`, else a first line that is no chapter heading. `Book.front_matter` holds the text above a document's first heading; `build_epub` puts it on the title page, and `parse_text_to_book` fills it instead of making that text a chapter. `parse_text_to_book` keeps each line of a text that has no blank line at all as its own paragraph. `convert_stage` and `download_convert_stage` pass a `user_facing` conversion error's message through unchanged and answer "The file could not be converted." for any other failure, so no library message or path reaches a user. `EpubDocument.drm_protected()` tells a DRM-protected book (a `rights.xml`, or an `encryption.xml` using anything but font obfuscation) from one whose fonts are only obfuscated.
72
+
67
73
  ### 3.2.0
68
74
 
69
75
  SPI 3.2. A story can name the multi-part work it is one part of: `StoryPatch.work` and
@@ -44,4 +44,4 @@ ruff D, pdoc, mutmut and the traceability gate all cover the SDK; it has its own
44
44
  ``pack/``, so a fetched index and a built one are validated by exactly the same rules.
45
45
  """
46
46
 
47
- __version__ = "3.2.0"
47
+ __version__ = "3.3.0"
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import hashlib
11
11
  import logging
12
+ import re
12
13
  from collections.abc import Callable, Mapping
13
14
  from contextlib import AbstractContextManager, nullcontext
14
15
  from pathlib import Path
@@ -26,7 +27,9 @@ from ebookerr_sdk.download.check import (
26
27
  from ebookerr_sdk.download.disposition import parse_content_disposition_filename
27
28
  from ebookerr_sdk.download.identity import USER_AGENT as _UA
28
29
  from ebookerr_sdk.download.paths import safe_component as _safe_component
29
- from ebookerr_sdk.epub.build import ConversionResult, DocumentConversionError
30
+ from ebookerr_sdk.epub.build import ConversionResult, DocumentConversionError, retitle_built_epub
31
+ from ebookerr_sdk.epub.errors import EpubError
32
+ from ebookerr_sdk.epub.text_convert import is_chapter_heading
30
33
  from ebookerr_sdk.spi import (
31
34
  BookPatch,
32
35
  BookView,
@@ -41,6 +44,14 @@ from ebookerr_sdk.spi import (
41
44
 
42
45
  _JUNK_AUTHORS = frozenset({"windows user", "user", "administrator", "unknown"})
43
46
  _MAX_FIRST_LINE_TITLE = 120
47
+ _COULD_NOT_CONVERT = "The file could not be converted."
48
+ """The reason a failed conversion gives when the converter named no cause (EXP-297)."""
49
+ _JUNK_FILE_NAME_RE = re.compile(
50
+ r"(?:(?:new )?(?:microsoft word |text )?document|doc|untitled(?: document)?"
51
+ r"|scan|img|image|dsc|pxl)[\s_-]*[\d_]*(?:\s*\(\d+\))?",
52
+ re.IGNORECASE,
53
+ )
54
+ """A file stem nobody chose: Document1, Untitled 2, New Text Document (3), scan_0023 (EXP-304)."""
44
55
 
45
56
 
46
57
  def _refusal(host: str, prior: BookView | None) -> str:
@@ -95,10 +106,58 @@ def _screen_author(raw: str | None, log: logging.Logger) -> str:
95
106
  return author
96
107
 
97
108
 
109
+ def _is_junk_file_name(stem: str) -> bool:
110
+ """Whether *stem* is a name nobody chose for a document (``EXP-304``).
111
+
112
+ Args:
113
+ stem: A file name without its extension.
114
+
115
+ Returns:
116
+ True for names like ``Document1``, ``Untitled``, ``New Document``, ``scan_0023``.
117
+ """
118
+ return bool(_JUNK_FILE_NAME_RE.fullmatch(stem.strip()))
119
+
120
+
121
+ def _upload_title(result: ConversionResult, stem: str, log: logging.Logger) -> str:
122
+ """Return an uploaded document's title (``EXP-304``).
123
+
124
+ Its own title (not the converter's file-name fallback); else the file name the user gave
125
+ it, unless that name names nothing (:func:`_is_junk_file_name`); else its first non-empty
126
+ line when that is no chapter heading and at most ``_MAX_FIRST_LINE_TITLE`` characters; else
127
+ the file name after all. The pull path keeps :func:`_document_title` (``EXP-224``).
128
+
129
+ Args:
130
+ result: What the converter reported.
131
+ stem: The uploaded file's name without its extension.
132
+ log: The plugin's logger (the choice is logged at DEBUG).
133
+
134
+ Returns:
135
+ The sanitised title.
136
+ """
137
+ embedded = "" if result.title_from_filename else sanitize(result.title)
138
+ if embedded:
139
+ return embedded
140
+ name = sanitize(stem) or stem
141
+ if not _is_junk_file_name(name):
142
+ log.debug("Title taken from the file name: %r", name)
143
+ return name
144
+ first_line = sanitize(result.first_line or "") or ""
145
+ usable = len(first_line) <= _MAX_FIRST_LINE_TITLE and not is_chapter_heading(first_line)
146
+ if first_line and usable:
147
+ log.debug(
148
+ "Title taken from the first line %r (the file name %r names nothing)", first_line, name
149
+ )
150
+ return first_line
151
+ log.debug("Title fell back to the file name %r (no document title, no usable first line)", name)
152
+ return name
153
+
154
+
98
155
  def _document_title(
99
156
  result: ConversionResult, fallback: str, log: logging.Logger, *, fallback_label: str
100
157
  ) -> str:
101
- """Return the converted document's title by the ``EXP-224`` precedence.
158
+ """Return the converted document's title by the ``EXP-224`` pull-path precedence.
159
+
160
+ Used by the pull path only; an upload is titled by :func:`_upload_title`.
102
161
 
103
162
  The document's own title (ignored when the converter says it is only the file name), then
104
163
  its first non-empty line when it is at most ``_MAX_FIRST_LINE_TITLE`` characters, then
@@ -132,6 +191,67 @@ def _document_title(
132
191
  return title
133
192
 
134
193
 
194
+ def _write_chosen_title(epub_path: Path, built: str, title: str, log: logging.Logger) -> None:
195
+ """Write the stage's chosen *title* into the EPUB when the converter built another (EXP-304).
196
+
197
+ Args:
198
+ epub_path: The converted EPUB, still in its work folder.
199
+ built: The title the converter built the EPUB with (``ConversionResult.title``).
200
+ title: The title the stage chose for the book.
201
+ log: The plugin's logger.
202
+ """
203
+ if title == built:
204
+ return
205
+ try:
206
+ retitle_built_epub(epub_path, title)
207
+ except EpubError as exc:
208
+ log.warning("Could not write the title %r into %s: %s", title, epub_path.name, exc)
209
+ return
210
+ log.debug("Retitled the converted EPUB from %r to %r", built, title)
211
+
212
+
213
+ def _run_converter(
214
+ convert: Callable[[Path, Path], ConversionResult],
215
+ src: Path,
216
+ epub_path: Path,
217
+ log: logging.Logger,
218
+ log_label: str,
219
+ ) -> ConversionResult:
220
+ """Run the converter; every failure becomes a ``SourcePullError`` with a sentence (EXP-297).
221
+
222
+ A ``DocumentConversionError`` whose class sets ``user_facing = True`` carries the
223
+ converter's own sentence (damaged, password-protected, no text) and is passed through as
224
+ it is. Any other ``DocumentConversionError`` is logged at WARNING with its message, any
225
+ other exception at ERROR with its traceback (a converter defect); both answer
226
+ :data:`_COULD_NOT_CONVERT`. A library's message, an exception class name or a path never
227
+ reaches the reason.
228
+
229
+ Args:
230
+ convert: The converter, ``(source_path, epub_path) -> ConversionResult``.
231
+ src: The document to convert.
232
+ epub_path: Where the EPUB is written.
233
+ log: The plugin's logger.
234
+ log_label: The format's name in log lines (e.g. ``"PDF"``).
235
+
236
+ Returns:
237
+ What the converter reported.
238
+
239
+ Raises:
240
+ SourcePullError: The conversion failed; its message is a sentence for the user.
241
+ """
242
+ try:
243
+ return convert(src, epub_path)
244
+ except DocumentConversionError as exc:
245
+ if getattr(exc, "user_facing", False) is True:
246
+ log.debug("%s conversion of %s refused: %s", log_label, src.name, exc)
247
+ raise SourcePullError(str(exc)) from exc
248
+ log.warning("%s conversion of %s failed: %s", log_label, src.name, exc)
249
+ raise SourcePullError(_COULD_NOT_CONVERT) from exc
250
+ except Exception as exc:
251
+ log.exception("%s conversion of %s failed unexpectedly", log_label, src.name)
252
+ raise SourcePullError(_COULD_NOT_CONVERT) from exc
253
+
254
+
135
255
  def _stage_under_author(work_dir: Path, epub_filename: str, author: str) -> str:
136
256
  """Move ``work_dir/epub_filename`` into its author folder and return the relative path.
137
257
 
@@ -178,6 +298,8 @@ def download_convert_stage( # noqa: C901 — download/verify/convert/stage step
178
298
  then finally the basename's stem. The author is
179
299
  screened against a junk-author list (``windows user``, ``user``, ``administrator``,
180
300
  ``unknown``); if matched (case-insensitive) or empty, it becomes ``"Unknown"``.
301
+ The chosen title is written into the EPUB (package, table of contents, title page) when
302
+ the converter built it with another one.
181
303
 
182
304
  Args:
183
305
  url: URL to download
@@ -200,7 +322,9 @@ def download_convert_stage( # noqa: C901 — download/verify/convert/stage step
200
322
  URL is the download URL itself — a one-file download is one chapter at one address.
201
323
 
202
324
  Raises:
203
- SourcePullError: On download, conversion, or staging errors.
325
+ SourcePullError: On download, conversion, or staging errors; a conversion failure's
326
+ message is the converter's own sentence, else "The file could not be converted."
327
+ (EXP-297).
204
328
  ContentTypeMismatchError: When the server's Content-Type contradicts the claimed format.
205
329
  TaskCancelled: If the user cancelled the task at one of the two checkpoints.
206
330
  """
@@ -346,11 +470,8 @@ def download_convert_stage( # noqa: C901 — download/verify/convert/stage step
346
470
  file_size = src_path.stat().st_size
347
471
  log.debug("Converting %s to EPUB: %s (%d bytes)", log_label, src_path.name, file_size)
348
472
 
349
- # Convert to EPUB
350
- try:
351
- result = convert(src_path, work_dir / epub_filename)
352
- except DocumentConversionError as exc:
353
- raise SourcePullError(f"{log_label} conversion failed: {exc}") from exc
473
+ # Convert to EPUB; a failure is a sentence, never a library message (EXP-297)
474
+ result = _run_converter(convert, src_path, work_dir / epub_filename, log, log_label)
354
475
 
355
476
  # Author, title (EXP-224) and the staged path — the rules convert_stage shares
356
477
  author = _screen_author(result.author, log)
@@ -358,6 +479,7 @@ def download_convert_stage( # noqa: C901 — download/verify/convert/stage step
358
479
  title = sanitize(Path(cd_name).stem) or epub_stem
359
480
  else:
360
481
  title = _document_title(result, epub_stem, log, fallback_label="URL path stem")
482
+ _write_chosen_title(work_dir / epub_filename, result.title, title, log)
361
483
  output_filename = _stage_under_author(work_dir, epub_filename, author)
362
484
 
363
485
  # Clean up downloaded source
@@ -431,8 +553,10 @@ def convert_stage(
431
553
  The converter Sources' ``convert`` (SPI 2.33) calls this for a file the user uploaded. It
432
554
  hashes *path* (the same ``content_hash``/``content_length`` custom values the download path
433
555
  writes, under *namespace*), converts it into ``work_dir/<file stem>.epub``, and stages that
434
- under the author folder with the download path's rules (author screening, the ``EXP-224``
435
- title precedence with the file name as the last resort). *path* is only read.
556
+ under the author folder with the download path's author screening and the ``EXP-304`` upload
557
+ title precedence (``_upload_title``). The chosen title is written into
558
+ the EPUB (package, table of contents, title page) when the converter built it with another
559
+ one. *path* is only read.
436
560
 
437
561
  Args:
438
562
  path: The uploaded file; its stem is the title of last resort.
@@ -449,8 +573,8 @@ def convert_stage(
449
573
  *work_dir*.
450
574
 
451
575
  Raises:
452
- SourcePullError: The converter raised ``DocumentConversionError`` (its message is kept,
453
- prefixed ``"<log_label> conversion failed: "``).
576
+ SourcePullError: The conversion failed; its message is the converter's own sentence,
577
+ else "The file could not be converted." (EXP-297).
454
578
  """
455
579
  # SDK host bridges plugin.* loggers into the core as the plugin's lines.
456
580
  log = logging.getLogger(f"plugin.{namespace}")
@@ -459,12 +583,10 @@ def convert_stage(
459
583
  epub_stem = _safe_component(path.stem)
460
584
  epub_filename = f"{epub_stem}.epub"
461
585
  log.debug("Converting %s to EPUB: %s (%d bytes)", log_label, path.name, size)
462
- try:
463
- result = convert(path, work_dir / epub_filename)
464
- except DocumentConversionError as exc:
465
- raise SourcePullError(f"{log_label} conversion failed: {exc}") from exc
586
+ result = _run_converter(convert, path, work_dir / epub_filename, log, log_label)
466
587
  author = _screen_author(result.author, log)
467
- title = _document_title(result, epub_stem, log, fallback_label="file name")
588
+ title = _upload_title(result, epub_stem, log)
589
+ _write_chosen_title(work_dir / epub_filename, result.title, title, log)
468
590
  output_filename = _stage_under_author(work_dir, epub_filename, author)
469
591
  log.info(
470
592
  'Converted %s "%s" (%d chapters) from %s',