diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6ff2923be..f5b6b6cfd 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -700,11 +700,11 @@ On the **Alongside Exports** tab. A contact sheet shows the whole roll as film s * **Contact Sheet…**: opens the sheet. Drag an edge of the paper to resize it. * **Film format**: 35mm, 35mm Half Frame or 120 (with **Frame size** for 120). * **Paper**, **Width**, **Height** and **DPI**: the sheet's size and resolution. The default paper holds a 36-exposure roll. -* **Order**: by date, or by scene with a new strip or row for each scene. +* **Order**: by file name, by date, or by scene with a new strip or row for each scene. * **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure and normal contrast, so you see which negatives are thin or dense. * **Pick Frames**: click a frame to leave it out or put it back. Rejects start out. * **Edge Print**: the film maker's markings along the edges, such as the film name and frame numbers. -* **Roll Label**: the roll name, film, camera and date above the strips. +* **Roll Label**: the roll name, film, camera and date above the strips. Type your own text in **Label text** to print it instead. * **White Paper**: prints on white, which saves ink on a home printer. * **Film Edges**: off prints the frames upright, each at its own shape, in rows as on the Light Table. The sheet then has no perforations and no **Edge Print**. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 951373963..a67425f7f 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -117,7 +117,7 @@ from negpy.services.export.contact_sheet_roll import ( FrameFacts, SheetFrame, - creation_order, + name_order, infer_format, roll_label_text, sheet_look, @@ -8559,7 +8559,7 @@ def _on_contact_sheet_prepared(self, generation: int, facts: list) -> None: return self._contact_sheet_pending = None self.set_status("", 0) - frames = creation_order( + frames = name_order( [SheetFrame(asset, config, fact or FrameFacts()) for asset, config, fact in zip(pending["assets"], pending["configs"], facts)] ) try: diff --git a/negpy/desktop/view/widgets/contact_sheet_dialog.py b/negpy/desktop/view/widgets/contact_sheet_dialog.py index 98b3bba9e..cfe9f5491 100644 --- a/negpy/desktop/view/widgets/contact_sheet_dialog.py +++ b/negpy/desktop/view/widgets/contact_sheet_dialog.py @@ -6,7 +6,7 @@ import numpy as np from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QColor, QImage, QPainter, QPen -from PyQt6.QtWidgets import QDialog, QHBoxLayout, QPushButton, QSizePolicy, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QDialog, QHBoxLayout, QLineEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget from negpy.desktop.view.canvas.overlay import draw_resize_handles, draw_view_badge, hit_resize_handle, resize_cursor, view_badge_width from negpy.desktop.view.styles.templates import ( @@ -18,6 +18,7 @@ set_hint_kind, tool_toggle, with_alpha, + wrap_tooltip, ) from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.dialog_geometry import remember_dialog_geometry @@ -33,6 +34,7 @@ ILFORD_PAPERS, PAPER_MAX, PAPER_MIN, + SHEET_ORDERS, ContactSheetSettings, SheetPlan, best_dpi, @@ -50,6 +52,7 @@ SheetFrame, SheetLook, StraightProof, + creation_order, print_aspect, scene_breaks, scene_of, @@ -63,8 +66,8 @@ _CUSTOM_PAPER = len(ILFORD_PAPERS) _PRINTS = (tr("As Edited"), tr("Straight Proof")) _AS_EDITED, _STRAIGHT_PROOF = 0, 1 -_ORDERS = (mark("Date"), mark("Scene")) -_BY_DATE, _BY_SCENE = 0, 1 +_ORDERS = (mark("Name"), mark("Date"), mark("Scene")) +_BY_NAME, _BY_DATE, _BY_SCENE = 0, 1, 2 _EDITED_TILES, _PROOF_TILES, _SCENE_PROOF_TILES = 0, 1, 2 _CANVAS_PAD = 28.0 _TILE_REPAINT_MS = 120 @@ -308,8 +311,10 @@ def __init__( self._tile_shapes = self._shape_sets[_EDITED_TILES] self._turns = [0] * len(self._frames) self._has_scenes = any(scene_of(frame) for frame in self._frames) - self._by_scene = settings.by_scene and self._has_scenes - self._display = scene_order(self._frames) if self._by_scene else list(range(len(self._frames))) + order = SHEET_ORDERS.index(settings.order) + self._order = _BY_NAME if order == _BY_SCENE and not self._has_scenes else order + self._by_scene = self._order == _BY_SCENE + self._display = self._order_indices(self._order) self._page = 0 self._plan: Optional[SheetPlan] = None self._look = look_for(film_format) @@ -410,12 +415,12 @@ def _build_controls(self, settings: ContactSheetSettings) -> QWidget: self.order_btn = StepCombo.of( [tr(order) for order in _ORDERS], - tr("Lay the frames out in the order they were taken, or start a new strip for each scene."), + tr("Lay the frames out by file name, in the order they were taken, or with a new strip for each scene."), ) self.order_btn.set_item_enabled(_BY_SCENE, self._has_scenes) if not self._has_scenes: self.order_btn.set_item_tooltip(_BY_SCENE, tr("No frame of this roll is in a scene")) - self.order_btn.setCurrentIndex(_BY_SCENE if self._by_scene else _BY_DATE) + self.order_btn.setCurrentIndex(self._order) self.order_btn.currentIndexChanged.connect(self._on_order_changed) col.addLayout(field_row(tr("Order"), self.order_btn)) @@ -437,22 +442,23 @@ def _build_controls(self, settings: ContactSheetSettings) -> QWidget: self.pick_btn.toggled.connect(lambda _checked: self._schedule_render()) col.addWidget(self.pick_btn) - toggles = QHBoxLayout() - toggles.setSpacing(THEME.space_md) + self.film_edges_btn = labeled_toggle( + "fa5s.film", + tr("Film Edges"), + settings.film_edges, + tr( + "Print the frames on film strips with their perforations and edge print; off lays them upright in rows, as on the Light Table" + ), + ) + self.film_edges_btn.toggled.connect(self._on_film_edges_toggled) self.edge_btn = labeled_toggle( "fa5s.barcode", tr("Edge Print"), settings.edge_print, tr("Print the maker's edge markings: stock name, frame numbers and the DX barcode"), ) + self.edge_btn.setEnabled(settings.film_edges) self.edge_btn.toggled.connect(lambda _checked: self._restyle()) - toggles.addWidget(self.edge_btn, 1) - self.label_btn = labeled_toggle( - "fa5s.tag", tr("Roll Label"), settings.roll_label, tr("Print the roll name, film, developer, camera and date above the strips") - ) - self.label_btn.toggled.connect(self._on_label_toggled) - toggles.addWidget(self.label_btn, 1) - col.addLayout(toggles) self.white_btn = labeled_toggle( "fa5s.print", tr("White Paper"), @@ -460,21 +466,26 @@ def _build_controls(self, settings: ContactSheetSettings) -> QWidget: tr("Print on white paper with dark labels, to save ink on a home printer"), ) self.white_btn.toggled.connect(lambda _checked: self._schedule_render()) - self.film_edges_btn = labeled_toggle( - "fa5s.film", - tr("Film Edges"), - settings.film_edges, - tr( - "Print the frames on film strips with their perforations and edge print; off lays them upright in rows, as on the Light Table" - ), + self.label_btn = labeled_toggle( + "fa5s.tag", tr("Roll Label"), settings.roll_label, tr("Print the roll name, film, developer, camera and date above the strips") ) - self.edge_btn.setEnabled(settings.film_edges) - self.film_edges_btn.toggled.connect(self._on_film_edges_toggled) - second = QHBoxLayout() - second.setSpacing(THEME.space_md) - second.addWidget(self.white_btn, 1) - second.addWidget(self.film_edges_btn, 1) - col.addLayout(second) + self.label_btn.toggled.connect(self._on_label_toggled) + for pair in ((self.film_edges_btn, self.edge_btn), (self.white_btn, self.label_btn)): + row = QHBoxLayout() + row.setSpacing(THEME.space_md) + for button in pair: + row.addWidget(button, 1) + col.addLayout(row) + self.label_edit = QLineEdit() + self.label_edit.setPlaceholderText(self._label_text) + self.label_edit.setToolTip( + wrap_tooltip(tr("Your own text for the roll label, or the roll name, film, camera and date when this is empty.")) + ) + self.label_edit.textChanged.connect(lambda _text: self._schedule_render()) + # Return commits the text instead of pressing Export. + self.label_edit.installEventFilter(self) + self.label_edit.setEnabled(settings.roll_label) + col.addLayout(field_row(tr("Label text"), self.label_edit)) self.summary_label = hint_label() self.summary_label.setWordWrap(True) @@ -502,8 +513,8 @@ def _build_controls(self, settings: ContactSheetSettings) -> QWidget: combos = (self.format_btn, self.frame_size_btn, self.paper_btn, self.dpi_btn, self.order_btn, self.print_btn) # One width, the widest choice's, so no choice elides and the column keeps one right edge. width = max(combo.minimumSizeHint().width() for combo in combos) - for combo in combos: - combo.setMinimumWidth(width) + for field in (*combos, self.label_edit): + field.setMinimumWidth(width) return panel def _geometry(self): @@ -621,7 +632,8 @@ def _film_changed(self) -> None: def _on_dpi_changed(self, index: int) -> None: self._dpi = DPI_CHOICES[index] - def _on_label_toggled(self, _checked: bool) -> None: + def _on_label_toggled(self, checked: bool) -> None: + self.label_edit.setEnabled(checked) self._set_paper(*self._paper, snap=False) def _set_page(self, page: int) -> None: @@ -634,9 +646,17 @@ def _request_tiles(self, variant: int) -> None: return self._generations[self._source.render(frames)] = variant + def _order_indices(self, order: int) -> list[int]: + if order == _BY_SCENE: + return scene_order(self._frames) + if order == _BY_DATE: + return creation_order(self._frames) + return list(range(len(self._frames))) + def _on_order_changed(self, index: int) -> None: + self._order = index self._by_scene = index == _BY_SCENE - self._display = scene_order(self._frames) if self._by_scene else list(range(len(self._frames))) + self._display = self._order_indices(index) self._apply_variant() def _apply_variant(self) -> None: @@ -773,6 +793,9 @@ def resizeEvent(self, event) -> None: # noqa: N802 def eventFilter(self, obj, event) -> bool: # noqa: N802 if event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + if obj is self.label_edit: + obj.clearFocus() + return True if obj in (self.width_slider.spin, self.height_slider.spin): obj.interpretText() obj.clearFocus() @@ -806,7 +829,7 @@ def settings(self) -> ContactSheetSettings: self._dpi, self._label_on(), self.edge_btn.isChecked(), - self.order_btn.currentIndex() == _BY_SCENE, + SHEET_ORDERS[self._order], self.white_btn.isChecked(), self.film_edges_btn.isChecked(), ) @@ -838,6 +861,6 @@ def look(self) -> SheetLook: return replace( self._look, edge=edge, - label=self._label_text if self._label_on() else "", + label=(self.label_edit.text().strip() or self._label_text) if self._label_on() else "", white_paper=self.white_btn.isChecked(), ) diff --git a/negpy/services/export/contact_sheet_layout.py b/negpy/services/export/contact_sheet_layout.py index a75e30172..d9f1899c8 100644 --- a/negpy/services/export/contact_sheet_layout.py +++ b/negpy/services/export/contact_sheet_layout.py @@ -328,6 +328,9 @@ def frame_turns(upright_aspect: Optional[float], geometry: FilmGeometry, rotatio return 3 +SHEET_ORDERS = ("name", "date", "scene") + + @dataclass(frozen=True) class ContactSheetSettings: """The film format is not stored: it is read from the frames each time.""" @@ -337,7 +340,7 @@ class ContactSheetSettings: dpi: int = DEFAULT_DPI roll_label: bool = True edge_print: bool = True - by_scene: bool = False + order: str = "name" # one of SHEET_ORDERS white_paper: bool = False film_edges: bool = True # off prints the frames upright in rows, as the Light Table shows them @@ -364,7 +367,7 @@ def flag(key: str, fallback: bool) -> bool: dpi=dpi if dpi in DPI_CHOICES and not isinstance(dpi, bool) else default.dpi, roll_label=flag("roll_label", default.roll_label), edge_print=flag("edge_print", default.edge_print), - by_scene=flag("by_scene", default.by_scene), + order=data.get("order") if data.get("order") in SHEET_ORDERS else default.order, white_paper=flag("white_paper", default.white_paper), film_edges=flag("film_edges", default.film_edges), ) @@ -376,7 +379,7 @@ def to_dict(self) -> dict[str, Any]: "dpi": self.dpi, "roll_label": self.roll_label, "edge_print": self.edge_print, - "by_scene": self.by_scene, + "order": self.order, "white_paper": self.white_paper, "film_edges": self.film_edges, } diff --git a/negpy/services/export/contact_sheet_roll.py b/negpy/services/export/contact_sheet_roll.py index 42f306d08..e6962b426 100644 --- a/negpy/services/export/contact_sheet_roll.py +++ b/negpy/services/export/contact_sheet_roll.py @@ -188,15 +188,20 @@ def read_frame_facts(asset: dict) -> FrameFacts: ) -def creation_order(frames: Sequence[SheetFrame]) -> list[SheetFrame]: - """EXIF capture time when every frame has one, else file creation time: mixing them interleaves two clocks.""" +def name_order(frames: Sequence[SheetFrame]) -> list[SheetFrame]: + return sorted(frames, key=lambda frame: (frame.name.lower(), frame.half)) + + +def creation_order(frames: Sequence[SheetFrame]) -> list[int]: + """Indices by EXIF capture time when every frame has one, else by file creation time: mixing them interleaves two clocks.""" by_exif = bool(frames) and all(f.facts.capture_time for f in frames) - def key(frame: SheetFrame) -> tuple[Any, ...]: + def key(index: int) -> tuple[Any, ...]: + frame = frames[index] when: Any = frame.facts.capture_time if by_exif else frame.facts.birth_time return (when, frame.name.lower(), frame.half) - return sorted(frames, key=key) + return sorted(range(len(frames)), key=key) def _crop_fractions(config: WorkspaceConfig) -> tuple[float, float]: diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 6ff2923be..f5b6b6cfd 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -700,11 +700,11 @@ On the **Alongside Exports** tab. A contact sheet shows the whole roll as film s * **Contact Sheet…**: opens the sheet. Drag an edge of the paper to resize it. * **Film format**: 35mm, 35mm Half Frame or 120 (with **Frame size** for 120). * **Paper**, **Width**, **Height** and **DPI**: the sheet's size and resolution. The default paper holds a 36-exposure roll. -* **Order**: by date, or by scene with a new strip or row for each scene. +* **Order**: by file name, by date, or by scene with a new strip or row for each scene. * **Print**: **As Edited**, or **Straight Proof**: the whole roll at one exposure and normal contrast, so you see which negatives are thin or dense. * **Pick Frames**: click a frame to leave it out or put it back. Rejects start out. * **Edge Print**: the film maker's markings along the edges, such as the film name and frame numbers. -* **Roll Label**: the roll name, film, camera and date above the strips. +* **Roll Label**: the roll name, film, camera and date above the strips. Type your own text in **Label text** to print it instead. * **White Paper**: prints on white, which saves ink on a home printer. * **Film Edges**: off prints the frames upright, each at its own shape, in rows as on the Light Table. The sheet then has no perforations and no **Edge Print**. diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index 9c3f8281e..9b20d3cec 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -503,7 +503,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **自動濃度/グレード**:**書き出し時に確定**は、書き出した各フレームの自動の明るさとコントラストを固定し、後で変わらないようにします。**ロールに確定**は表示中のすべてのフレームで今すぐ固定します。 - **編集サイドカー**:**書き出し時に保存**は各元ファイルの横に編集を含む `.negpy` ファイルを書きます。**サイドカーのエクスポート**は表示中のすべてのフレームで書きます。フレームを右クリック →**サイドカーから編集をロード…**で読み戻します。 - + ## コンタクトシート **書き出しと一緒に**タブにあります。コンタクトシートは、暗室のコンタクトプリントのように、ロール全体をフィルムの帯として 1 枚の紙に並べます。 @@ -512,11 +512,11 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **コンタクトシート…**:シートを開きます。紙の端をドラッグしてサイズを変えます。 - **フィルムフォーマット**:35mm、35mm ハーフサイズ、120(120 は**フレームサイズ**も設定)。 - **印画紙**、**幅**、**高さ**、**DPI**:シートのサイズと解像度。既定の紙で 36 枚撮りのロールが収まります。 -- **順序**:日付順、またはシーンごとに新しい帯か行を始めるシーン順。 +- **順序**:ファイル名順、日付順、またはシーンごとに新しい帯か行を始めるシーン順。 - **プリント**:**編集済み**、または**ストレートプルーフ**:ロール全体を一つの露光と標準のコントラストでプリントし、どのネガが薄いか濃いかを見られます。 - **フレームの選択**:フレームをクリックして外したり戻したりします。不採用のフレームは最初から外れています。 - **エッジプリント**:フィルム名やフレーム番号など、フィルムの縁にあるメーカーの印字。 -- **ロールラベル**:帯の上にロール名、フィルム、カメラ、日付を入れます。 +- **ロールラベル**:帯の上にロール名、フィルム、カメラ、日付を入れます。**ラベルの文字**に独自の文字を入力すると、代わりにその文字を入れます。 - **白い紙**:白地にプリントし、家庭用プリンターのインクを節約します。 - **フィルムの縁**:オフにすると、フレームを正立のままそれぞれの形で、ライトテーブルと同じく行に並べます。このときシートにはパーフォレーションも**エッジプリント**もありません。 diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index c5db136f1..5383fc4c3 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -503,7 +503,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **자동 농도 / 등급**: **내보낼 때 고정**은 내보낸 각 프레임의 자동 밝기와 대비를 고정해 나중에 바뀌지 않게 합니다. **롤에 고정**은 보이는 모든 프레임에 지금 적용합니다. - **편집 사이드카**: **내보내기 시 저장**은 각 원본 옆에 편집을 담은 `.negpy` 파일을 씁니다. **사이드카 내보내기**는 보이는 모든 프레임에 씁니다. 프레임 오른쪽 클릭 → **사이드카에서 편집 로드…**로 다시 읽습니다. - + ## 컨택트 시트 **내보내기와 함께** 탭에 있습니다. 컨택트 시트는 암실의 밀착 인화처럼 롤 전체를 필름 띠로 종이 한 장에 보여줍니다. @@ -512,11 +512,11 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **컨택트 시트…**: 시트를 엽니다. 종이 가장자리를 드래그해 크기를 바꿉니다. - **필름 규격**: 35mm, 35mm 하프 프레임, 120(120은 **프레임 크기**도 설정). - **인화지**, **폭**, **높이**, **DPI**: 시트의 크기와 해상도입니다. 기본 용지에 36컷 롤이 다 들어갑니다. -- **순서**: 날짜순, 또는 장면마다 새 띠나 줄을 시작하는 장면순. +- **순서**: 파일 이름순, 날짜순, 또는 장면마다 새 띠나 줄을 시작하는 장면순. - **인화**: **편집됨**, 또는 **스트레이트 프루프**: 롤 전체를 한 노출과 보통 대비로 인화해 어느 네거티브가 옅거나 짙은지 보여줍니다. - **프레임 선택**: 프레임을 클릭해 빼거나 다시 넣습니다. 거부한 프레임은 처음부터 빠져 있습니다. - **가장자리 인화**: 필름 이름, 프레임 번호 같은 필름 가장자리의 제조사 표시입니다. -- **롤 라벨**: 띠 위에 롤 이름, 필름, 카메라, 날짜를 넣습니다. +- **롤 라벨**: 띠 위에 롤 이름, 필름, 카메라, 날짜를 넣습니다. **라벨 문구**에 직접 입력하면 그 문구를 대신 넣습니다. - **흰 종이**: 흰 바탕에 인화해 가정용 프린터의 잉크를 아낍니다. - **필름 가장자리**: 끄면 라이트 테이블처럼 프레임을 바로 세워 각자의 모양 그대로 줄로 배치합니다. 이때 시트에는 퍼포레이션도 **가장자리 인화**도 없습니다. diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index 0fd57f56d..7bd291831 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -503,7 +503,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **自动密度 / 反差**:**导出时固化**会固定每个导出帧的自动亮度和反差,使其以后不再变化。**固化到胶卷**立即对所有可见帧执行。 - **编辑附属文件**:**导出时保存**在每个源文件旁写入含编辑内容的 `.negpy` 文件。**导出附属文件**为所有可见帧写入。右键点击某帧 → **从附属文件加载编辑…**可读回。 - + ## 接触印相 位于**随导出**选项卡。接触印相像暗房印样一样,把整卷以胶片条的形式排在一张纸上。 @@ -512,11 +512,11 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **接触印相…**:打开印样。拖动纸张边缘可改变大小。 - **胶片画幅**:35mm、35mm 半格或 120(120 还需设置**画面尺寸**)。 - **相纸**、**宽度**、**高度**和 **DPI**:印样的尺寸和分辨率。默认相纸可容纳 36 张一卷。 -- **顺序**:按日期,或按场景(每个场景另起一条或一行)。 +- **顺序**:按文件名、按日期,或按场景(每个场景另起一条或一行)。 - **印相**:**按编辑结果**,或**直接样片**:整卷以同一曝光和常规反差印出,便于看出哪些底片偏薄或偏厚。 - **选择画面**:点击某帧可将其排除或放回。拒绝的帧一开始就被排除。 - **边缘印相**:胶片边缘的厂商标记,如胶片名称和帧编号。 -- **胶卷标签**:在胶片条上方印上胶卷名称、胶片、相机和日期。 +- **胶卷标签**:在胶片条上方印上胶卷名称、胶片、相机和日期。在**标签文字**中输入自定义文字,即改印该文字。 - **白纸**:印在白底上,节省家用打印机的墨水。 - **胶片边缘**:关闭时画面正向、按各自形状排成多行,与灯箱一致。此时印样没有齿孔,也没有**边缘印相**。 diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index 86350a86c..4dcab7ded 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -1298,6 +1298,7 @@ "LED LEVELS": "LED レベル", "LENS CORRECTION": "レンズ補正", "Lab": "ラボ", + "Label text": "ラベルの文字", "Language": "言語", "Largest dust size to remove, where larger values can remove real detail.": "取り除くほこりの最大サイズで、大きくすると本来の細部まで消えることがあります。", "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU が一度に読み込む画像の最大サイズ。0 ならハードウェアに任せます。", @@ -1306,7 +1307,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "後の編集でワークプリントが消えることはありません。削除できるのはここだけです。", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "緯度と経度、または貼り付けた OpenStreetMap か Google マップのリンク。", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "表示されているすべてのフレームをフィルム ストリップとして印画紙に配置し、シートをエクスポートします。", - "Lay the frames out in the order they were taken, or start a new strip for each scene.": "フレームを撮影した順に並べるか、シーンごとに新しいストリップを始めます。", + "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "フレームをファイル名順か撮影した順に並べるか、シーンごとに新しいストリップを始めます。", "Leave at the default unless you know why you need to change it.": "変更する必要がある理由がわからない場合は、デフォルトのままにしておきます。", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "リジェクト済みのフレームを除外し、残りに連番を付けます。", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "現在のビュー(のぞき見、分割、ストリップ、ゾーンオーバーレイ)を終了するか、アクティブなツールをキャンセルします。トリミングと範囲を描画は変更を元に戻します", @@ -2905,6 +2906,7 @@ "You have not added any {value} yet.": "まだ {value} を追加していません。", "Your Scanning Setup": "スキャン環境", "Your own notes on this item": "このアイテムについての自分のメモ", + "Your own text for the roll label, or the roll name, film, camera and date when this is empty.": "ロールラベルに入れる独自の文字です。空の場合はロール名、フィルム、カメラ、日付を入れます。", "Your presets": "自分のプリセット", "Your saved edits stay in the database. The roll stays in the Library.": "保存した編集内容はデータベースに残ります。ロールはライブラリに残ります。", "Your saved edits stay in the database. This only removes the frames from the list.": "保存した編集内容はデータベースに残ります。これによりリストからフレームが削除されるだけです。", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index 1d51fb30e..d8ce78a44 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -1298,6 +1298,7 @@ "LED LEVELS": "LED 레벨", "LENS CORRECTION": "렌즈 보정", "Lab": "랩", + "Label text": "라벨 문구", "Language": "언어", "Largest dust size to remove, where larger values can remove real detail.": "제거할 먼지의 최대 크기로, 크게 하면 실제 세부도 지울 수 있습니다.", "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU가 한 번에 불러오는 이미지의 최대 크기입니다. 0이면 하드웨어가 정합니다.", @@ -1306,7 +1307,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "이후의 편집으로 워크 프린트가 지워지지 않습니다. 삭제하는 방법은 이것뿐입니다.", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "위도와 경도, 또는 붙여 넣은 OpenStreetMap이나 Google Maps 링크.", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "눈에 보이는 모든 프레임을 인화지 위에 필름 스트립으로 배치한 다음 시트를 내보냅니다.", - "Lay the frames out in the order they were taken, or start a new strip for each scene.": "프레임을 찍은 순서대로 놓거나, 장면마다 새 스트립을 시작합니다.", + "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "프레임을 파일 이름순이나 찍은 순서대로 놓거나, 장면마다 새 스트립을 시작합니다.", "Leave at the default unless you know why you need to change it.": "변경해야 하는 이유를 알지 못하는 한 기본값을 그대로 유지합니다.", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "거부된 프레임은 제외하고 남은 프레임에 연속 번호를 지정합니다.", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "현재 보기(피크, 분할, 스트립, 존 오버레이)를 종료하거나 활성 도구를 취소합니다. 자르기와 영역 그리기는 변경을 되돌립니다.", @@ -2905,6 +2906,7 @@ "You have not added any {value} yet.": "아직 {value}을(를) 추가하지 않았습니다.", "Your Scanning Setup": "스캔 환경", "Your own notes on this item": "이 항목에 대한 내 메모", + "Your own text for the roll label, or the roll name, film, camera and date when this is empty.": "롤 라벨에 넣을 직접 입력한 문구입니다. 비어 있으면 롤 이름, 필름, 카메라, 날짜를 넣습니다.", "Your presets": "내 사전 설정", "Your saved edits stay in the database. The roll stays in the Library.": "저장된 편집 내용은 데이터베이스에 유지됩니다. 롤은 라이브러리에 남아 있습니다.", "Your saved edits stay in the database. This only removes the frames from the list.": "저장된 편집 내용은 데이터베이스에 유지됩니다. 이렇게 하면 목록에서 프레임만 제거됩니다.", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index 39cbfb511..17b69b66f 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -1298,6 +1298,7 @@ "LED LEVELS": "LED 亮度", "LENS CORRECTION": "镜头校正", "Lab": "Lab", + "Label text": "标签文字", "Language": "语言", "Largest dust size to remove, where larger values can remove real detail.": "要去除的最大灰尘尺寸,数值大时可能误删真实细节。", "Largest image size the GPU loads at once. 0 lets the hardware decide.": "GPU 一次载入的最大图像尺寸。0 表示由硬件决定。", @@ -1306,7 +1307,7 @@ "Later edits never delete a work print; this is the only way to delete one.": "之后的编辑不会删除工作样片;只有这里能删除它。", "Latitude and longitude, or a pasted OpenStreetMap or Google Maps link.": "经纬度,或粘贴的 OpenStreetMap 或 Google 地图链接。", "Lay every visible frame out as film strips on photographic paper, then export the sheet": "将所有可见画面按胶片条排列在相纸上并导出", - "Lay the frames out in the order they were taken, or start a new strip for each scene.": "按拍摄顺序排列画面,或为每个场景另起一条。", + "Lay the frames out by file name, in the order they were taken, or with a new strip for each scene.": "按文件名或拍摄顺序排列画面,或为每个场景另起一条。", "Leave at the default unless you know why you need to change it.": "除非明确需要更改,请保留默认值。", "Leave frames in the rejected state out of the run; the numbers fill the frames that remain.": "跳过已拒绝的画面,其余画面连续编号。", "Leave the current view (peek, split, strip, zone overlay) or cancel the active tool; Crop and Draw Region undo their changes": "退出当前查看模式,或取消活动工具;裁切和绘制区域会撤销其更改", @@ -2905,6 +2906,7 @@ "You have not added any {value} yet.": "尚未添加{value}。", "Your Scanning Setup": "你的扫描设备", "Your own notes on this item": "你对此条目的备注", + "Your own text for the roll label, or the roll name, film, camera and date when this is empty.": "胶卷标签的自定义文字;为空时使用胶卷名称、胶片、相机和日期。", "Your presets": "你的预设", "Your saved edits stay in the database. The roll stays in the Library.": "已保存编辑保留在数据库,胶卷仍留在图库。", "Your saved edits stay in the database. This only removes the frames from the list.": "已保存编辑保留在数据库,仅从列表移除画面。", diff --git a/tests/test_contact_sheet_controller.py b/tests/test_contact_sheet_controller.py index ddd316252..1e728268f 100644 --- a/tests/test_contact_sheet_controller.py +++ b/tests/test_contact_sheet_controller.py @@ -100,11 +100,11 @@ def test_no_output_folder_ends_the_request(self): self.controller.request_contact_sheet() self.controller.contact_sheet_preview.prepare.assert_not_called() - def test_accepted_dialog_queues_the_frames_in_creation_order_with_their_own_edits(self): + def test_accepted_dialog_queues_the_frames_in_name_order_with_their_own_edits(self): mock_cls = self._dialog() self.controller.request_contact_sheet() self.controller.contact_sheet_preview.prepare.assert_called_once() - self._prepared(birth_a=10.0, birth_b=20.0) + self._prepared(birth_a=20.0, birth_b=10.0) frames = mock_cls.call_args.args[0] self.assertEqual([f.name for f in frames], ["a.tif", "b.tif"]) diff --git a/tests/test_contact_sheet_dialog.py b/tests/test_contact_sheet_dialog.py index db716e559..a0c80618f 100644 --- a/tests/test_contact_sheet_dialog.py +++ b/tests/test_contact_sheet_dialog.py @@ -203,6 +203,16 @@ def test_roll_label_toggle_reaches_the_settings(dialog): d.label_btn.setChecked(False) assert d.settings().roll_label is False assert d.look().label == "" + assert not d.label_edit.isEnabled() + + +def test_label_text_replaces_the_roll_label(dialog): + d = dialog() + assert d.label_edit.placeholderText() == "ROLL 1" + d.label_edit.setText(" Summer in Lisbon ") + assert d.look().label == "Summer in Lisbon" + d.label_edit.setText("") + assert d.look().label == "ROLL 1" def test_rejected_frames_start_left_out(dialog): @@ -295,18 +305,28 @@ def _scened(frames, scenes): def test_scene_order_needs_scenes(dialog): d = dialog(n=4) - assert not d.order_btn.is_item_enabled(1) + assert not d.order_btn.is_item_enabled(2) + assert dialog(n=4, settings=ContactSheetSettings(order="scene")).order_btn.currentIndex() == 0 + + +def test_date_order_follows_the_capture_time_and_keeps_roll_numbers(dialog): + frames = [replace(f, facts=FrameFacts(scan_size=f.facts.scan_size, birth_time=t)) for f, t in zip(_frames(3), (30, 10, 20))] + d = dialog(frames=frames) + assert d.order_btn.currentIndex() == 0 and d.numbers() == (0, 1, 2) + d.order_btn.setCurrentIndex(1) + assert d.numbers() == (1, 2, 0) + assert d.settings().order == "date" def test_scene_order_groups_frames_and_starts_each_scene_on_a_new_strip(dialog): frames = _scened(_frames(8), [2, 1, 1, 0, 2, 1, 0, 2]) d = dialog(frames=frames) - d.order_btn.setCurrentIndex(1) + d.order_btn.setCurrentIndex(2) assert d.numbers() == (1, 2, 5, 0, 4, 7, 3, 6) assert d.breaks() == (3, 6) assert [(s.first, s.count) for s in d.plan().pages[0].strips] == [(0, 3), (3, 3), (6, 2)] assert [f.name for f in d.kept_frames()] == ["1.tif", "2.tif", "5.tif", "0.tif", "4.tif", "7.tif", "3.tif", "6.tif"] - assert d.settings().by_scene is True + assert d.settings().order == "scene" d.order_btn.setCurrentIndex(0) assert d.numbers() == tuple(range(8)) and d.breaks() == () @@ -324,7 +344,7 @@ def test_scene_order_proofs_each_scene_at_its_own_metering(dialog): ) d.print_btn.setCurrentIndex(1) assert d.kept_frames()[0].config.process.locked_floors == (-2.0,) * 3 - d.order_btn.setCurrentIndex(1) + d.order_btn.setCurrentIndex(2) assert d.kept_frames()[0].config.process.locked_floors == (-3.0,) * 3 assert tiles.requests[-1] == scene_proofed assert "own metering" in d.proof_label.text() @@ -335,7 +355,7 @@ def test_a_proof_unavailable_in_scene_order_falls_back_to_as_edited(dialog): roll_proof = tuple(replace(f, config=straight_proof_config(f.config, (-2.0,) * 3, (-0.5,) * 3)) for f in frames) d = dialog(frames=frames, proof=StraightProof(roll_proof, "", "roll"), scene_proof=StraightProof(reason="Run Scene Analysis first.")) d.print_btn.setCurrentIndex(1) - d.order_btn.setCurrentIndex(1) + d.order_btn.setCurrentIndex(2) assert d.print_btn.currentIndex() == 0 and not d.straight_proof() assert "Scene Analysis" in d.proof_label.text() diff --git a/tests/test_contact_sheet_layout.py b/tests/test_contact_sheet_layout.py index 72c936791..11fdb2d8f 100644 --- a/tests/test_contact_sheet_layout.py +++ b/tests/test_contact_sheet_layout.py @@ -205,6 +205,11 @@ def test_film_edges_round_trip_and_default_on(self): assert ContactSheetSettings.from_dict(settings.to_dict()).film_edges is False assert ContactSheetSettings.from_dict({"film_edges": "no"}).film_edges is True + def test_order_round_trips_and_defaults_to_name(self): + assert ContactSheetSettings.from_dict(ContactSheetSettings(order="date").to_dict()).order == "date" + assert ContactSheetSettings.from_dict({"order": "size"}).order == "name" + assert ContactSheetSettings.from_dict({"by_scene": True}).order == "name" + def test_edge_print_defaults_on_for_records_without_it(self): assert ContactSheetSettings.from_dict({"dpi": 600}).edge_print is True diff --git a/tests/test_contact_sheet_roll.py b/tests/test_contact_sheet_roll.py index e6c3f087c..51b0f726e 100644 --- a/tests/test_contact_sheet_roll.py +++ b/tests/test_contact_sheet_roll.py @@ -17,6 +17,7 @@ FrameFacts, SheetFrame, creation_order, + name_order, cropped_aspect, infer_format, parse_frame_120, @@ -54,7 +55,7 @@ def get_film_stock(self, stock_id): class TestOrder: def test_exif_time_when_every_frame_has_one(self): frames = [_frame("b.jpg", capture="2026-01-01 10:00:02", birth=1), _frame("a.jpg", capture="2026-01-01 10:00:01", birth=2)] - assert [f.name for f in creation_order(frames)] == ["a.jpg", "b.jpg"] + assert creation_order(frames) == [1, 0] def test_file_creation_time_when_any_frame_lacks_exif(self): frames = [ @@ -62,11 +63,15 @@ def test_file_creation_time_when_any_frame_lacks_exif(self): _frame("b.jpg", capture="", birth=10), _frame("c.jpg", capture="2026-01-01 10:00:00", birth=20), ] - assert [f.name for f in creation_order(frames)] == ["b.jpg", "c.jpg", "a.jpg"] + assert creation_order(frames) == [1, 2, 0] def test_ties_break_by_name_then_half(self): frames = [_frame("x.tif", half=2, birth=5), _frame("x.tif", half=1, birth=5), _frame("a.tif", birth=5)] - assert [(f.name, f.half) for f in creation_order(frames)] == [("a.tif", 0), ("x.tif", 1), ("x.tif", 2)] + assert creation_order(frames) == [2, 1, 0] + + def test_name_order_ignores_case_and_time(self): + frames = [_frame("b.tif", birth=1), _frame("x.tif", half=2), _frame("A.tif", birth=9), _frame("x.tif", half=1)] + assert [(f.name, f.half) for f in name_order(frames)] == [("A.tif", 0), ("b.tif", 0), ("x.tif", 1), ("x.tif", 2)] class TestFileFacts: