tinyprint 0.2.1__tar.gz → 0.2.3__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 (30) hide show
  1. {tinyprint-0.2.1 → tinyprint-0.2.3}/PKG-INFO +29 -1
  2. tinyprint-0.2.3/README.md +31 -0
  3. {tinyprint-0.2.1 → tinyprint-0.2.3}/pyproject.toml +2 -2
  4. tinyprint-0.2.3/tests/test_gui.py +312 -0
  5. {tinyprint-0.2.1 → tinyprint-0.2.3}/tests/test_integration.py +2 -2
  6. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/gui.py +4 -1
  7. tinyprint-0.2.3/tinyprint/patches/__init__.py +5 -0
  8. tinyprint-0.2.3/tinyprint/patches/escpos.py +172 -0
  9. tinyprint-0.2.3/tinyprint/patches/mako.py +26 -0
  10. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/preprocessor.py +17 -1
  11. tinyprint-0.2.3/tinyprint/printer/__init__.py +2 -0
  12. tinyprint-0.2.3/tinyprint/printer/manager.py +137 -0
  13. tinyprint-0.2.3/tinyprint/printer/profile.py +91 -0
  14. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/PKG-INFO +29 -1
  15. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/SOURCES.txt +8 -1
  16. tinyprint-0.2.1/README.md +0 -3
  17. {tinyprint-0.2.1 → tinyprint-0.2.3}/LICENSE +0 -0
  18. {tinyprint-0.2.1 → tinyprint-0.2.3}/setup.cfg +0 -0
  19. {tinyprint-0.2.1 → tinyprint-0.2.3}/tests/test_job.py +0 -0
  20. {tinyprint-0.2.1 → tinyprint-0.2.3}/tests/test_jobconfig.py +0 -0
  21. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/__init__.py +0 -0
  22. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/__main__.py +0 -0
  23. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/config.py +0 -0
  24. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/gui_misc.py +0 -0
  25. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/job.py +0 -0
  26. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint/jobconfig.py +0 -0
  27. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/dependency_links.txt +0 -0
  28. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/entry_points.txt +0 -0
  29. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/requires.txt +0 -0
  30. {tinyprint-0.2.1 → tinyprint-0.2.3}/tinyprint.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyprint
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: ui & cli to print zines with esc/pos printer
5
5
  Author-email: Levin Eric Zimmermann <levin.eric.zimmermann@posteo.com>
6
6
  License: GPL
@@ -23,3 +23,31 @@ Dynamic: license-file
23
23
  # tinyprint
24
24
 
25
25
  Software for printing tiny zines with ESC/POS printer.
26
+
27
+ ![gui screenshot](screenshot.jpg)
28
+
29
+ ## Installation
30
+
31
+ ### MacOS
32
+
33
+ Open a console and type:
34
+
35
+ ```bash
36
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)
37
+ brew install python
38
+ brew install python-tk
39
+ brew install pipx
40
+ pipx install tinyprint
41
+ ```
42
+
43
+ ## Linux
44
+
45
+ Install python, and then just use pip:
46
+
47
+ ```bash
48
+ pip3 install tinyprint
49
+ ```
50
+
51
+ ## Windows
52
+
53
+ I don't know, but if someone manages, please let us know.
@@ -0,0 +1,31 @@
1
+ # tinyprint
2
+
3
+ Software for printing tiny zines with ESC/POS printer.
4
+
5
+ ![gui screenshot](screenshot.jpg)
6
+
7
+ ## Installation
8
+
9
+ ### MacOS
10
+
11
+ Open a console and type:
12
+
13
+ ```bash
14
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)
15
+ brew install python
16
+ brew install python-tk
17
+ brew install pipx
18
+ pipx install tinyprint
19
+ ```
20
+
21
+ ## Linux
22
+
23
+ Install python, and then just use pip:
24
+
25
+ ```bash
26
+ pip3 install tinyprint
27
+ ```
28
+
29
+ ## Windows
30
+
31
+ I don't know, but if someone manages, please let us know.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tinyprint"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  authors = [
5
5
  {name = "Levin Eric Zimmermann", email = "levin.eric.zimmermann@posteo.com"},
6
6
  ]
@@ -28,7 +28,7 @@ dev = [
28
28
  text = "GPL"
29
29
 
30
30
  [tool.setuptools]
31
- packages = ["tinyprint"]
31
+ packages = ["tinyprint", "tinyprint.patches", "tinyprint.printer"]
32
32
 
33
33
  [project.scripts]
34
34
  tinyprint = "tinyprint.__main__:main"
@@ -0,0 +1,312 @@
1
+ """Tests for the TinyPrint GUI."""
2
+
3
+ import json
4
+ import pytest
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+ import tempfile
8
+ import shutil
9
+
10
+ # Import the GUI components
11
+ from tinyprint.gui import GUI, JobConfigManager
12
+ from tinyprint.jobconfig import JobConfig, Resource
13
+
14
+
15
+ @pytest.fixture
16
+ def temp_dir():
17
+ """Create and cleanup a temporary directory for test files."""
18
+ temp_dir = tempfile.mkdtemp(prefix="tinyprint-test-")
19
+ yield temp_dir
20
+ shutil.rmtree(temp_dir, ignore_errors=True)
21
+
22
+
23
+ @pytest.fixture
24
+ def gui_app(temp_dir):
25
+ """Create a GUI instance for testing with a temporary directory."""
26
+ with GUI() as gui:
27
+ yield gui
28
+
29
+
30
+ @pytest.fixture
31
+ def job_controller(gui_app):
32
+ """Return the JobController instance from the GUI."""
33
+ return gui_app.blk.job_controller
34
+
35
+
36
+ def test_print_button_starts_job(job_controller):
37
+ """Test that clicking the print button starts a print job."""
38
+ # Setup - mock the Job class
39
+ mock_job = MagicMock()
40
+ mock_job.is_running = False
41
+
42
+ with patch("tinyprint.gui.Job", return_value=mock_job) as mock_job_class:
43
+ # Simulate clicking the print button
44
+ job_controller.btn.print.invoke()
45
+
46
+ # Verify a job was created with the correct config
47
+ mock_job_class.assert_called_once()
48
+ assert isinstance(mock_job_class.call_args[0][0], JobConfig)
49
+
50
+ # Verify the job was started
51
+ mock_job.start.assert_called_once()
52
+ assert job_controller.job == mock_job
53
+
54
+
55
+ def test_stop_button_stops_job(job_controller):
56
+ """Test that clicking the stop button stops a running print job."""
57
+ # Setup - create a mock job
58
+ mock_job = MagicMock()
59
+ mock_job.is_running = True
60
+ job_controller.job = mock_job
61
+
62
+ # Simulate clicking the stop button
63
+ job_controller.btn.stop.invoke()
64
+
65
+ # Verify the job was stopped and cleaned up
66
+ mock_job.stop.assert_called_once()
67
+ mock_job.close.assert_called_once()
68
+ assert job_controller.job is None
69
+
70
+
71
+ @pytest.fixture
72
+ def job_config_manager(gui_app):
73
+ """Create a JobConfigManager instance for testing."""
74
+ gui_app.blk = MagicMock()
75
+ gui_app.blk.job_config_editor = MagicMock()
76
+ gui_app.blk.job_config_editor.sync_from_model = MagicMock()
77
+ gui_app.tempdir = tempfile.mkdtemp()
78
+ return JobConfigManager(gui_app)
79
+
80
+
81
+ def test_job_config_manager_initialization(job_config_manager):
82
+ """Test that JobConfigManager initializes correctly."""
83
+ assert job_config_manager.job_config is not None
84
+ assert isinstance(job_config_manager.job_config, JobConfig)
85
+ assert len(job_config_manager.job_config.resource_list) == 1
86
+ assert "tmp-resource.md" in job_config_manager.job_config.resource_list[0].path
87
+ assert job_config_manager.filename is not None
88
+ assert job_config_manager.filename.endswith("tmp-print-job.json")
89
+
90
+
91
+ def test_job_config_manager_new_config(job_config_manager, tmp_path):
92
+ """Test creating a new configuration."""
93
+ test_file = tmp_path / "test_config.json"
94
+
95
+ # Set up the save_as dialog to return our test file
96
+ with patch(
97
+ "tkinter.filedialog.asksaveasfilename", return_value=str(test_file)
98
+ ) as mock_save_as:
99
+ # Simulate clicking the new button
100
+ job_config_manager.btn.new.invoke()
101
+
102
+ assert job_config_manager.filename == str(test_file)
103
+ assert test_file.exists()
104
+
105
+ # Verify the file contains valid JSON with expected structure
106
+ with open(test_file, "r") as f:
107
+ config_data = json.load(f)
108
+ assert "resource_list" in config_data
109
+ assert isinstance(config_data["resource_list"], list)
110
+ assert len(config_data["resource_list"]) == 1
111
+
112
+
113
+ @patch("tkinter.filedialog.asksaveasfilename")
114
+ def test_job_config_manager_save_as(mock_save_as, job_config_manager, tmp_path):
115
+ """Test saving configuration to a new file."""
116
+ test_file = tmp_path / "test_save_as.json"
117
+ mock_save_as.return_value = str(test_file)
118
+
119
+ # Simulate clicking the save_as button
120
+ job_config_manager.btn.save_as.invoke()
121
+
122
+ mock_save_as.assert_called_once()
123
+ assert job_config_manager.filename == str(test_file)
124
+ assert test_file.exists()
125
+ # Verify the filename label is updated
126
+ assert (
127
+ job_config_manager.lbl.filename["textvariable"]
128
+ == job_config_manager.var.filename_indicator._name
129
+ )
130
+ assert job_config_manager.var.filename_indicator.get() == "test_save_as.json"
131
+
132
+
133
+ @patch("tkinter.filedialog.askopenfilename")
134
+ def test_job_config_manager_open_config(mock_open_file, job_config_manager, tmp_path):
135
+ """Test opening an existing configuration file."""
136
+ # Create a test config file
137
+ test_file = tmp_path / "test_open.json"
138
+ test_config = {
139
+ "resource_list": [{"path": "test.txt"}],
140
+ "printer_config": None,
141
+ "copy_count": 1,
142
+ "cut": True,
143
+ "orientation": 0, # 0 = Orientation.HORIZONTAL
144
+ "page_size": None,
145
+ "sleep_time": 0.5,
146
+ }
147
+ with open(test_file, "w") as f:
148
+ json.dump(test_config, f)
149
+
150
+ mock_open_file.return_value = str(test_file)
151
+
152
+ # Simulate clicking the open button
153
+ job_config_manager.btn.open.invoke()
154
+
155
+ mock_open_file.assert_called_once()
156
+ assert job_config_manager.filename == str(test_file)
157
+ assert job_config_manager.job_config is not None
158
+ assert len(job_config_manager.job_config.resource_list) == 1
159
+ assert job_config_manager.job_config.resource_list[0].path == "test.txt"
160
+ assert job_config_manager.job_config.sleep_time == 0.5
161
+ # Verify the filename label is updated
162
+ assert (
163
+ job_config_manager.lbl.filename["textvariable"]
164
+ == job_config_manager.var.filename_indicator._name
165
+ )
166
+ assert job_config_manager.var.filename_indicator.get() == "test_open.json"
167
+
168
+
169
+ def test_job_config_manager_reload_config(job_config_manager, tmp_path):
170
+ """Test reloading the current configuration from disk."""
171
+ # Create a test config file
172
+ test_file = tmp_path / "test_reload.json"
173
+ test_config = {
174
+ "resource_list": [{"path": "reload.txt"}],
175
+ "orientation": 0, # 0 = Orientation.HORIZONTAL
176
+ "sleep_time": 0.3,
177
+ }
178
+ with open(test_file, "w") as f:
179
+ json.dump(test_config, f)
180
+
181
+ # Set the filename and reload
182
+ job_config_manager.set_filename(str(test_file))
183
+ # Simulate clicking the reload button
184
+ job_config_manager.btn.reload.invoke()
185
+
186
+ # Verify the config was loaded correctly
187
+ assert job_config_manager.job_config is not None
188
+ assert len(job_config_manager.job_config.resource_list) == 1
189
+ assert job_config_manager.job_config.resource_list[0].path == "reload.txt"
190
+ assert job_config_manager.job_config.sleep_time == 0.3
191
+
192
+ # Verify the editor was synced
193
+ job_config_manager.gui.blk.job_config_editor.sync_from_model.assert_called_once()
194
+
195
+
196
+ def test_job_config_manager_set_filename(job_config_manager):
197
+ """Test updating the filename and UI indicator."""
198
+ test_path = "/path/to/test_config.json"
199
+ job_config_manager.set_filename(test_path)
200
+
201
+ assert job_config_manager.filename == test_path
202
+
203
+
204
+ def test_job_config_editor_initialization(gui_app):
205
+ """Test that JobConfigEditor initializes correctly."""
206
+ editor = gui_app.blk.job_config_editor
207
+ assert editor is not None
208
+ # Verify all expected variables are created
209
+ assert hasattr(editor.var, "resource_list")
210
+ assert hasattr(editor.var, "printer_config")
211
+ assert hasattr(editor.var, "sleep_time")
212
+ # Verify widgets are created
213
+ assert hasattr(editor.lbl, "resource_list")
214
+ assert hasattr(editor.lbl, "printer_value")
215
+ assert hasattr(editor.lbl, "sleep_value")
216
+ assert hasattr(editor.btn, "select_resource")
217
+ assert hasattr(editor.btn, "select_printer")
218
+ assert hasattr(editor.cbx, "cut")
219
+
220
+
221
+ @patch("tkinter.filedialog.askopenfilenames")
222
+ def test_select_resource(mock_file_dialog, gui_app):
223
+ """Test selecting resources updates the job config and UI."""
224
+ editor = gui_app.blk.job_config_editor
225
+ test_files = ["/path/to/file1.pdf", "/path/to/file2.jpg"]
226
+ mock_file_dialog.return_value = test_files
227
+ editor.btn.select_resource.invoke()
228
+ mock_file_dialog.assert_called_once()
229
+ assert len(editor.job_config.resource_list) == 2
230
+ assert editor.job_config.resource_list[0].path == test_files[0]
231
+ assert editor.job_config.resource_list[1].path == test_files[1]
232
+ assert test_files[0].split("/")[-1] in editor.var.resource_list.get()
233
+
234
+
235
+ @patch("tkinter.filedialog.askopenfilename")
236
+ def test_select_printer_config(mock_file_dialog, gui_app):
237
+ """Test selecting a printer config updates the job config and UI."""
238
+ editor = gui_app.blk.job_config_editor
239
+ test_file = "/path/to/printer.yml"
240
+ mock_file_dialog.return_value = test_file
241
+
242
+ # Simulate clicking the select printer button
243
+ editor.btn.select_printer.invoke()
244
+
245
+ # Get the actual call arguments
246
+ args, kwargs = mock_file_dialog.call_args
247
+ assert kwargs.get("title") == "Open a file"
248
+ assert kwargs.get("filetypes") == (
249
+ ("printer config", "*.yml"),
250
+ ("all files", "*.*"),
251
+ )
252
+ assert str(kwargs.get("initialdir")) == str(Path.home())
253
+
254
+ # Verify the printer config was updated
255
+ assert editor.job_config.printer_config == test_file
256
+ assert editor.var.printer_config.get() == test_file
257
+
258
+
259
+ def test_toggle_cut(gui_app):
260
+ """Test toggling the cut option updates the job config."""
261
+ editor = gui_app.blk.job_config_editor
262
+ initial_cut = editor.job_config.cut
263
+
264
+ # Set the combobox to the opposite of current cut state
265
+ new_value = "no" if initial_cut else "yes"
266
+ editor.cbx.cut.set(new_value)
267
+
268
+ # Trigger the combobox selection event
269
+ editor.cbx.cut.event_generate("<<ComboboxSelected>>")
270
+
271
+ # Verify the cut value has been toggled
272
+ assert editor.job_config.cut != initial_cut
273
+ assert editor.job_config.cut == (new_value == "yes")
274
+
275
+
276
+ def test_change_sleep_time(gui_app):
277
+ """Test changing the sleep time updates the job config and UI."""
278
+ editor = gui_app.blk.job_config_editor
279
+ new_sleep_time = 1.5
280
+ editor.scl.sleep_time.set(new_sleep_time)
281
+ assert editor.job_config.sleep_time == new_sleep_time
282
+ assert editor.var.sleep_time.get() == new_sleep_time
283
+ assert float(editor.lbl.sleep_value["text"]) == new_sleep_time
284
+
285
+
286
+ def test_sync_from_model(gui_app):
287
+ """Test that sync_from_model updates the UI correctly."""
288
+ editor = gui_app.blk.job_config_editor
289
+ job_config_manager = editor.gui.blk.job_config_manager
290
+
291
+ # Create a test config
292
+ test_config = JobConfig(
293
+ resource_list=[Resource("test.txt")],
294
+ printer_config="test_printer.yml",
295
+ cut=True,
296
+ sleep_time=1.0,
297
+ )
298
+
299
+ # Set the job config and sync
300
+ job_config_manager.job_config = test_config
301
+ editor.sync_from_model()
302
+
303
+ # Verify UI is in sync with the model
304
+ assert "test.txt" in editor.var.resource_list.get()
305
+ assert editor.var.printer_config.get() == "test_printer.yml"
306
+ assert editor.cbx.cut.get() == "yes"
307
+ assert editor.var.sleep_time.get() == 1.0
308
+
309
+ # Verify filename indicator if it exists
310
+ if hasattr(job_config_manager.var, "filename_indicator"):
311
+ # The filename should be the default one set in JobConfigManager.__init__
312
+ assert job_config_manager.var.filename_indicator.get() == "tmp-print-job.json"
@@ -13,7 +13,7 @@ from tinyprint.job import Job
13
13
  from tinyprint.jobconfig import JobConfig, Resource, Orientation
14
14
 
15
15
  # Test data directory
16
- TEST_DATA = Path(__file__).parent / "data"
16
+ TEST_DATA = Path(__file__).absolute().parent / "data"
17
17
 
18
18
  # Test configurations
19
19
  RESOURCES = {
@@ -69,4 +69,4 @@ def test_print(snapshot, resource_path_list, config_kwargs):
69
69
  job.start()
70
70
  job.wait()
71
71
 
72
- assert snapshot == job.manager.printer.output
72
+ assert snapshot == job.manager.printer.output, resource_path_list
@@ -224,7 +224,10 @@ class JobConfigEditor(Block):
224
224
  )
225
225
 
226
226
  def select_file(self, attr, filetypes, multiple=False):
227
- if file_list := super().select_file(filetypes, multiple):
227
+ file_list = super().select_file(filetypes, multiple)
228
+ if multiple is False:
229
+ file_list = [file_list]
230
+ if file_list:
228
231
  setattr(
229
232
  self.job_config,
230
233
  attr,
@@ -0,0 +1,5 @@
1
+ """Monkey patches"""
2
+
3
+ from . import mako
4
+ from . import escpos
5
+
@@ -0,0 +1,172 @@
1
+ """Patching escpos library to cut between images without whitespace between
2
+
3
+ See comment at Job.print for more details.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ import logging
8
+ import time
9
+ from typing import Optional
10
+
11
+ import six # type: ignore
12
+
13
+ from escpos.image import EscposImage # type: ignore
14
+ from escpos.escpos import Escpos # type: ignore
15
+ from escpos.constants import ESC, GS # type: ignore
16
+ from escpos.exceptions import ImageWidthError # type: ignore
17
+ from escpos.printer.usb import Usb as UsbPrinter # type: ignore
18
+
19
+
20
+ @dataclass
21
+ class PrinterCommand:
22
+ """Wrapper for ESC/POS commands with an index for tracking command sequence.
23
+
24
+ This is used to track the position of commands for special handling,
25
+ particularly for paper cutting between images.
26
+ """
27
+ index: int # Position in command sequence
28
+ cmd: bytes # Raw ESC/POS command bytes
29
+
30
+
31
+
32
+ if 1:
33
+
34
+ def _image(
35
+ self,
36
+ img_source,
37
+ high_density_vertical: bool = True,
38
+ high_density_horizontal: bool = True,
39
+ impl: str = "bitImageRaster",
40
+ fragment_height: int = 960,
41
+ center: bool = False,
42
+ cmd_index_offset: int = 0,
43
+ ) -> Optional[int]:
44
+ """Print an image.
45
+
46
+ You can select whether the printer should print in high density or not. The default value is high density.
47
+ When printing in low density, the image will be stretched.
48
+
49
+ Esc/Pos supplies several commands for printing. This function supports three of them. Please try to vary the
50
+ implementations if you have any problems. For example the printer `IT80-002` will have trouble aligning
51
+ images that are not printed in Column-mode.
52
+
53
+ The available printing implementations are:
54
+
55
+ * `bitImageRaster`: prints with the `GS v 0`-command
56
+ * `graphics`: prints with the `GS ( L`-command
57
+ * `bitImageColumn`: prints with the `ESC *`-command
58
+
59
+ When trying to center an image make sure you have initialized the printer with a valid profile, that
60
+ contains a media width pixel field. Otherwise the centering will have no effect.
61
+
62
+ :param img_source: PIL image or filename to load: `jpg`, `gif`, `png` or `bmp`
63
+ :param high_density_vertical: print in high density in vertical direction *default:* True
64
+ :param high_density_horizontal: print in high density in horizontal direction *default:* True
65
+ :param impl: choose image printing mode between `bitImageRaster`, `graphics` or `bitImageColumn`
66
+ :param fragment_height: Images larger than this will be split into multiple fragments *default:* 960
67
+ :param center: Center image horizontally *default:* False
68
+
69
+ """
70
+ im = EscposImage(img_source)
71
+
72
+ try:
73
+ if self.profile.profile_data["media"]["width"]["pixels"] == "Unknown":
74
+ logging.debug(
75
+ "The media.width.pixel field of the printer profile is not set. "
76
+ + "The center flag will have no effect."
77
+ )
78
+
79
+ max_width = int(self.profile.profile_data["media"]["width"]["pixels"])
80
+
81
+ if im.width > max_width:
82
+ raise ImageWidthError(f"{im.width} > {max_width}")
83
+
84
+ if center:
85
+ im.center(max_width)
86
+ except KeyError:
87
+ # If the printer's pixel width is not known, print anyways...
88
+ pass
89
+ except ValueError:
90
+ # If the max_width cannot be converted to an int, print anyways...
91
+ pass
92
+
93
+ if im.height > fragment_height:
94
+ fragments = im.split(fragment_height)
95
+ cmd_index_offset = 0
96
+ for fragment in fragments:
97
+ cmd_index_offset = self.image(
98
+ fragment,
99
+ high_density_vertical=high_density_vertical,
100
+ high_density_horizontal=high_density_horizontal,
101
+ impl=impl,
102
+ fragment_height=fragment_height,
103
+ cmd_index_offset=cmd_index_offset,
104
+ )
105
+ # XXX dummy printer doesn't have '_sleep_in_fragment' method
106
+ # self._sleep_in_fragment()
107
+ return None
108
+
109
+ if impl == "bitImageRaster":
110
+ # GS v 0, raster format bit image
111
+ density_byte = (0 if high_density_horizontal else 1) + (
112
+ 0 if high_density_vertical else 2
113
+ )
114
+ header = (
115
+ GS
116
+ + b"v0"
117
+ + bytes((density_byte,))
118
+ + self._int_low_high(im.width_bytes, 2)
119
+ + self._int_low_high(im.height, 2)
120
+ )
121
+ self._raw(header + im.to_raster_format())
122
+
123
+ if impl == "graphics":
124
+ # GS ( L raster format graphics
125
+ img_header = self._int_low_high(im.width, 2) + self._int_low_high(
126
+ im.height, 2
127
+ )
128
+ tone = b"0"
129
+ colors = b"1"
130
+ ym = b"\x01" if high_density_vertical else b"\x02"
131
+ xm = b"\x01" if high_density_horizontal else b"\x02"
132
+ header = tone + xm + ym + colors + img_header
133
+ raster_data = im.to_raster_format()
134
+ self._image_send_graphics_data(b"0", b"p", header + raster_data)
135
+ self._image_send_graphics_data(b"0", b"2", b"")
136
+
137
+ if impl == "bitImageColumn":
138
+ # ESC *, column format bit image
139
+ density_byte = (1 if high_density_horizontal else 0) + (
140
+ 32 if high_density_vertical else 0
141
+ )
142
+ header = (
143
+ ESC
144
+ + b"*"
145
+ + six.int2byte(density_byte)
146
+ + self._int_low_high(im.width, 2)
147
+ )
148
+ self._raw(PrinterCommand(cmd_index_offset, ESC + b"3" + six.int2byte(16)))
149
+ for i, blob in enumerate(im.to_column_format(high_density_vertical)):
150
+ cmd_index = i + 1 + cmd_index_offset
151
+ self._raw(PrinterCommand(cmd_index, header + blob + b"\n"))
152
+ self._raw(ESC + b"2")
153
+ return cmd_index_offset + i + 1
154
+
155
+ return None
156
+
157
+ Escpos.image = _image
158
+
159
+
160
+ if 1:
161
+
162
+ # Usb printer: reduce likelihood for timeout gibberish
163
+
164
+ _Usb_raw_original = UsbPrinter._raw
165
+
166
+ def _Usb_raw(self, *args, **kwargs) -> None:
167
+ _Usb_raw_original(self, *args, **kwargs)
168
+ # NOTE Patch to reduce likelihood for timeout gibberish
169
+ while self._read():
170
+ time.sleep(0.01)
171
+
172
+ UsbPrinter._raw = _Usb_raw
@@ -0,0 +1,26 @@
1
+ from marko import block
2
+
3
+ # Allow multiple blank lines to create multiple blank lines
4
+
5
+
6
+ class BlankLine(block.BlockElement):
7
+ """Blank lines"""
8
+
9
+ priority = 5
10
+
11
+ def __init__(self, start: int) -> None:
12
+ self._anchor = start
13
+
14
+ @classmethod
15
+ def match(cls, source) -> bool:
16
+ line = source.next_line()
17
+ return line is not None and not line.strip()
18
+
19
+ @classmethod
20
+ def parse(cls, source) -> int:
21
+ m = source.match
22
+ source.consume()
23
+ return m
24
+
25
+
26
+ block.BlankLine = BlankLine # type: ignore
@@ -4,6 +4,7 @@ import abc
4
4
  from functools import cached_property
5
5
  import mimetypes
6
6
  import os
7
+ from pathlib import Path
7
8
  from tempfile import TemporaryDirectory
8
9
  from typing import Callable, TypeAlias, Optional
9
10
 
@@ -144,6 +145,7 @@ class MarkdownPreprocessor(Preprocessor):
144
145
  md = f.read()
145
146
 
146
147
  def _(printer):
148
+ self.markdown.renderer.set_markdown_path(resource.path)
147
149
  self.markdown.renderer.set_printer(printer)
148
150
  self.markdown.renderer.set_config(self.config)
149
151
  self.markdown.renderer.set_printer_profile(self.printer_profile)
@@ -192,7 +194,8 @@ class _EscposRenderer(marko.renderer.Renderer):
192
194
  return ""
193
195
 
194
196
  def render_image(self, element) -> str:
195
- resource = Resource(path=element.dest)
197
+ path = _resolve_path(element.dest, self._markdown_path)
198
+ resource = Resource(path=path)
196
199
  get_image_page(resource, self._config, self._printer_profile)(self._printer)
197
200
  return ""
198
201
 
@@ -241,6 +244,9 @@ class _EscposRenderer(marko.renderer.Renderer):
241
244
  def set_printer_profile(self, printer_profile):
242
245
  self._printer_profile = printer_profile
243
246
 
247
+ def set_markdown_path(self, markdown_path):
248
+ self._markdown_path = markdown_path
249
+
244
250
  def _set(self, *args, **kwargs):
245
251
  if self._printer:
246
252
  self._printer.set(*args, **kwargs)
@@ -273,3 +279,13 @@ class _EscposRenderer(marko.renderer.Renderer):
273
279
  @max_char_count.setter
274
280
  def max_char_count(self, max_char_count: int):
275
281
  self._max_char_count = max_char_count
282
+
283
+
284
+ def _resolve_path(path: str, markdown_path: str) -> str:
285
+ p = Path(path)
286
+
287
+ if p.is_absolute():
288
+ return path
289
+
290
+ markdown_dir = Path(markdown_path).parent
291
+ return str((markdown_dir / p).resolve())
@@ -0,0 +1,2 @@
1
+ from . import manager
2
+ from . import profile
@@ -0,0 +1,137 @@
1
+ """Printer communication manager for handling ESC/POS printer interactions."""
2
+
3
+ import functools
4
+ import logging
5
+ import time
6
+ import threading
7
+ from typing import Optional
8
+
9
+ import six
10
+ import usb # type: ignore
11
+
12
+ from escpos.constants import GS # type: ignore
13
+ from escpos.config import Config as escpos_Config # type: ignore
14
+ from escpos.printer import Dummy # type: ignore
15
+
16
+ from tinyprint.printer.profile import patch_printer_profile
17
+
18
+
19
+ class Manager:
20
+ """Manages printer communication including connection handling and command sending."""
21
+
22
+ def __init__(self, config):
23
+ """Initialize the printer manager.
24
+
25
+ Args:
26
+ config: Configuration object containing printer settings.
27
+ """
28
+ self.config = config
29
+ self._logger = logging.getLogger(f"{__name__}.Manager")
30
+ self._print_job: Optional[threading.Thread] = None
31
+ self.is_printing = False
32
+
33
+ @functools.cached_property
34
+ def printer(self):
35
+ if not self.config.printer_config:
36
+ self._logger.warning("no printer config: use dummy printer")
37
+ p = Dummy()
38
+ else:
39
+ c = escpos_Config()
40
+ c.load(self.config.printer_config)
41
+ p = c.printer()
42
+ patch_printer_profile(p)
43
+ return p
44
+
45
+ def print(self, cmd_list):
46
+ """Start printing in background thread"""
47
+ self.is_printing = True
48
+
49
+ def _():
50
+ self._print(cmd_list)
51
+ self.is_printing = False
52
+
53
+ self._print_job = threading.Thread(target=_)
54
+ self._print_job.start()
55
+
56
+ def _print(self, cmd_list: list[bytes]):
57
+ try:
58
+ for cmd in cmd_list:
59
+ if not self.is_printing:
60
+ break
61
+ self._print1(cmd)
62
+ finally:
63
+ # No matter what happens - make final cut.
64
+ # Final cut
65
+ # 66 => move to cut position
66
+ # 00 => then make full cut
67
+ self.printer._raw(GS + b"V" + six.int2byte(66) + b"\x00")
68
+
69
+ def _print1(self, cmd: bytes):
70
+ printer = self.printer
71
+ sleep_time = self.config.sleep_time or printer.profile.sleep_time
72
+ try:
73
+ printer._raw(cmd)
74
+ # NOTE ( USB device printer fix )
75
+ #
76
+ # Do not lose any command:
77
+ # Python sends faster commands than the matrix
78
+ # printer can print. And the USB device doesn't
79
+ # block until one print command is finished, but
80
+ # returns immediately. If we run into a USBTimeout,
81
+ # this becomes a serious problem, because then
82
+ # we restart the printer & only resend the last command.
83
+ # In case the previous command wasn't processed yet,
84
+ # we'd lose this command (usually the paper cut).
85
+ # To avoid this, we wait for a little time, to better
86
+ # synchronize Python and the Matrix printer.
87
+ if sleep_time and len(cmd) > 5:
88
+ self._sleep(sleep_time)
89
+ # NOTE ( USB device printer fix )
90
+ # Don't give up when a time out happens - it seems USB
91
+ # connection is sometimes unstable & breaks.
92
+ except usb.core.USBTimeoutError:
93
+ self._logger.warning("timed out ... reset printer")
94
+ # We first read all data from printer to reduce the
95
+ # likelihood that the printer outputs glitchy gibberish
96
+ # to the print.
97
+ while printer._read():
98
+ time.sleep(0.01)
99
+ # Then reset printer to make it workable again
100
+ self._sleep(10)
101
+ printer.close()
102
+ self._sleep(10)
103
+ printer.open()
104
+ self._sleep(10)
105
+ # Finally try send command again
106
+ printer._raw(cmd)
107
+
108
+ def _sleep(self, total_time: float):
109
+ """Sleep that returns immediatelly if no longer printing"""
110
+ check_interval: float = 1
111
+ end_time = time.monotonic() + total_time
112
+
113
+ while time.monotonic() < end_time:
114
+ if not self.is_printing:
115
+ return
116
+ time.sleep(min(check_interval, end_time - time.monotonic()))
117
+
118
+ def wait(self, timeout: Optional[float] = None):
119
+ """Wait/block until print is finished"""
120
+ if self._print_job:
121
+ self._print_job.join(timeout)
122
+ self._print_job = None
123
+ self.is_printing = False
124
+
125
+ def stop(self):
126
+ """Stop currently running printing"""
127
+ if self._print_job and self._print_job.is_alive():
128
+ self.is_printing = False
129
+ self.wait()
130
+
131
+ def close(self):
132
+ self._logger.info("close printer")
133
+ self.stop()
134
+ self.printer.close()
135
+
136
+ def __del__(self):
137
+ self.close()
@@ -0,0 +1,91 @@
1
+ """Printer profile management for the tinyprint application.
2
+
3
+ This module provides functionality to manage and configure printer profiles
4
+ for different ESC/POS compatible printers. It handles printer-specific settings
5
+ and configurations to ensure optimal printing results across different printer
6
+ models.
7
+
8
+ The module includes functionality to:
9
+ - Apply custom configurations to printer profiles
10
+ - Handle printer model-specific settings
11
+ - Configure connection-specific parameters
12
+ - Set default values for various printing parameters
13
+ """
14
+
15
+ from escpos.printer.usb import Usb as UsbPrinter # type: ignore
16
+
17
+
18
+ def patch_printer_profile(printer):
19
+ """Apply custom configuration to a printer's profile.
20
+
21
+ This function extends the printer's profile with additional settings and
22
+ configurations that are not included in the default ESC/POS profile. It
23
+ handles model-specific configurations and sets appropriate defaults for
24
+ various printing parameters.
25
+ """
26
+ profile = printer.profile
27
+
28
+ # Default values in case printer is unknown
29
+ profile.img_kwargs = {}
30
+ profile.img_cut_index = 3
31
+ profile.img_scale_factor = 1
32
+ profile.img_x_scale_factor = 1
33
+ profile.img_y_scale_factor = 1
34
+ profile.sleep_time = 0
35
+
36
+ # How many characters fit on one line with normal font size.
37
+ # 42 is also default for TM-T88III.
38
+ profile.default_char_count = 42
39
+ # How many characters fit on one line with heading font size.
40
+ # 21 is also default for TM-T88III.
41
+ profile.big_char_count = 21
42
+
43
+ # Special treatment for specific printer models.
44
+ match profile.profile_data["name"]:
45
+ case "TM-T88III":
46
+ profile.img_kwargs = dict(
47
+ impl="bitImageColumn",
48
+ high_density_vertical=True,
49
+ high_density_horizontal=True,
50
+ center=False,
51
+ )
52
+ profile.default_char_count = 42
53
+ profile.big_char_count = 21
54
+ profile.img_cut_index = 4
55
+ case "TM-U220B":
56
+ profile.img_kwargs = dict(
57
+ impl="bitImageColumn",
58
+ high_density_vertical=False,
59
+ high_density_horizontal=False,
60
+ center=False,
61
+ fragment_height=32,
62
+ )
63
+ # NOTE Numbers need less space than letters in case of this
64
+ # printer (there is space for 40 numbers). Let's use the
65
+ # smaller number to make sure everything always fits in one
66
+ # line.
67
+ profile.default_char_count = 33
68
+ profile.big_char_count = 16
69
+ profile.img_cut_index = 9
70
+ # Profile is wrong, it's 200 dots, not 400
71
+ # https://files.support.epson.com/pdf/pos/bulk/tm-u220_trg_en_std_reve.pdf
72
+ # https://github.com/receipt-print-hq/escpos-printer-db/pull/87/commits/242299097
73
+ profile.profile_data["media"]["width"]["pixels"] = 200
74
+
75
+ case _:
76
+ pass
77
+
78
+ # Special treatment for specific connections to printer.
79
+ match printer:
80
+ case UsbPrinter():
81
+ # Usb printer immediately returns, but is usually slower
82
+ # than Python - improve synchronicity & sleep after each
83
+ # print command. Otherwise we get a lot of timeouts &
84
+ # printed gibberish. The time here seems to depend on the
85
+ # printed material. If it's more dense & blackish / colorful,
86
+ # then we need a higher value to not run into any trouble.
87
+ # More spare material seems to be less problematic. Also
88
+ # the problem becomes bigger if we print multiple pages.
89
+ profile.sleep_time = 0.3
90
+ case _:
91
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyprint
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: ui & cli to print zines with esc/pos printer
5
5
  Author-email: Levin Eric Zimmermann <levin.eric.zimmermann@posteo.com>
6
6
  License: GPL
@@ -23,3 +23,31 @@ Dynamic: license-file
23
23
  # tinyprint
24
24
 
25
25
  Software for printing tiny zines with ESC/POS printer.
26
+
27
+ ![gui screenshot](screenshot.jpg)
28
+
29
+ ## Installation
30
+
31
+ ### MacOS
32
+
33
+ Open a console and type:
34
+
35
+ ```bash
36
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)
37
+ brew install python
38
+ brew install python-tk
39
+ brew install pipx
40
+ pipx install tinyprint
41
+ ```
42
+
43
+ ## Linux
44
+
45
+ Install python, and then just use pip:
46
+
47
+ ```bash
48
+ pip3 install tinyprint
49
+ ```
50
+
51
+ ## Windows
52
+
53
+ I don't know, but if someone manages, please let us know.
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ tests/test_gui.py
4
5
  tests/test_integration.py
5
6
  tests/test_job.py
6
7
  tests/test_jobconfig.py
@@ -17,4 +18,10 @@ tinyprint.egg-info/SOURCES.txt
17
18
  tinyprint.egg-info/dependency_links.txt
18
19
  tinyprint.egg-info/entry_points.txt
19
20
  tinyprint.egg-info/requires.txt
20
- tinyprint.egg-info/top_level.txt
21
+ tinyprint.egg-info/top_level.txt
22
+ tinyprint/patches/__init__.py
23
+ tinyprint/patches/escpos.py
24
+ tinyprint/patches/mako.py
25
+ tinyprint/printer/__init__.py
26
+ tinyprint/printer/manager.py
27
+ tinyprint/printer/profile.py
tinyprint-0.2.1/README.md DELETED
@@ -1,3 +0,0 @@
1
- # tinyprint
2
-
3
- Software for printing tiny zines with ESC/POS printer.
File without changes
File without changes
File without changes
File without changes
File without changes