1import sys
2from io import StringIO, BytesIO
3import argparse
4import os
5import logging
6import pytest
7
8from ctypes import c_ulonglong
9
10from ttfautohint._compat import ensure_binary, text_type
11from ttfautohint.options import (
12    validate_options, format_varargs, strong_stem_width,
13    stdin_or_input_path_type, stdout_or_output_path_type, parse_args,
14    stem_width_mode, StemWidthMode
15)
16
17
18class TestValidateOptions(object):
19
20    def test_no_input(self):
21        with pytest.raises(ValueError, match="No input file"):
22            validate_options({})
23
24    def test_unknown_keyword(self):
25        kwargs = dict(foo="bar")
26        with pytest.raises(TypeError, match="unknown keyword argument: 'foo'"):
27            validate_options(kwargs)
28
29        # 's' for plural
30        kwargs = dict(foo="bar", baz=False)
31        with pytest.raises(TypeError,
32                           match="unknown keyword arguments: 'foo', 'baz'"):
33            validate_options(kwargs)
34
35    def test_no_info_or_detailed_info(self, tmpdir):
36        msg = "no_info and detailed_info are mutually exclusive"
37        kwargs = dict(no_info=True, detailed_info=True)
38        with pytest.raises(ValueError, match=msg):
39            validate_options(kwargs)
40
41    def test_in_file_or_in_buffer(self, tmpdir):
42        msg = "in_file and in_buffer are mutually exclusive"
43        in_file = (tmpdir / "file1.ttf").ensure()
44        kwargs = dict(in_file=str(in_file), in_buffer=b"\x00\x01\x00\x00")
45        with pytest.raises(ValueError, match=msg):
46            validate_options(kwargs)
47
48    def test_control_file_or_control_buffer(self, tmpdir):
49        msg = "control_file and control_buffer are mutually exclusive"
50        control_file = (tmpdir / "ta_ctrl.txt").ensure()
51        kwargs = dict(in_buffer=b"\0\1\0\0",
52                      control_file=control_file,
53                      control_buffer=b"abcd")
54        with pytest.raises(ValueError, match=msg):
55            validate_options(kwargs)
56
57    def test_reference_file_or_reference_buffer(self, tmpdir):
58        msg = "reference_file and reference_buffer are mutually exclusive"
59        reference_file = (tmpdir / "ref.ttf").ensure()
60        kwargs = dict(in_buffer=b"\0\1\0\0",
61                      reference_file=reference_file,
62                      reference_buffer=b"\x00\x01\x00\x00")
63        with pytest.raises(ValueError, match=msg):
64            validate_options(kwargs)
65
66    def test_in_file_to_in_buffer(self, tmpdir):
67        in_file = tmpdir / "file1.ttf"
68        data = b"\0\1\0\0"
69        in_file.write_binary(data)
70
71        # 'in_file' is a file-like object
72        options = validate_options({'in_file': in_file.open(mode="rb")})
73        assert options["in_buffer"] == data
74        assert "in_file" not in options
75        assert options["in_buffer_len"] == len(data)
76
77        # 'in_file' is a path string
78        options = validate_options({"in_file": str(in_file)})
79        assert options["in_buffer"] == data
80        assert "in_file" not in options
81        assert options["in_buffer_len"] == len(data)
82
83    def test_in_buffer_is_bytes(self, tmpdir):
84        with pytest.raises(TypeError, match="in_buffer type must be bytes"):
85            validate_options({"in_buffer": u"abcd"})
86
87    def test_control_file_to_control_buffer(self, tmpdir):
88        control_file = tmpdir / "ta_ctrl.txt"
89        data = u"abcd"
90        control_file.write_text(data, encoding="utf-8")
91
92        # 'control_file' is a file object opened in text mode
93        with control_file.open(mode="rt", encoding="utf-8") as f:
94            kwargs = {'in_buffer': b"\0", 'control_file': f}
95            options = validate_options(kwargs)
96        assert options["control_buffer"] == data.encode("utf-8")
97        assert "control_file" not in options
98        assert options["control_buffer_len"] == len(data)
99        assert options["control_name"] == str(control_file)
100
101        # 'control_file' is a path string
102        kwargs = {'in_buffer': b"\0", 'control_file': str(control_file)}
103        options = validate_options(kwargs)
104        assert options["control_buffer"] == data.encode("utf-8")
105        assert "control_file" not in options
106        assert options["control_buffer_len"] == len(data)
107        assert options["control_name"] == str(control_file)
108
109        # 'control_file' is a file-like stream
110        kwargs = {'in_buffer': b"\0", 'control_file': StringIO(data)}
111        options = validate_options(kwargs)
112        assert options["control_buffer"] == data.encode("utf-8")
113        assert "control_file" not in options
114        assert options["control_buffer_len"] == len(data)
115        # the stream doesn't have a 'name' attribute; using fallback
116        assert options["control_name"] == u"<control-instructions>"
117
118    def test_control_buffer_name(self, tmpdir):
119        kwargs = {"in_buffer": b"\0", "control_buffer": b"abcd"}
120        options = validate_options(kwargs)
121        assert options["control_name"] == u"<control-instructions>"
122
123    def test_reference_file_to_reference_buffer(self, tmpdir):
124        reference_file = tmpdir / "font.ttf"
125        data = b"\0\1\0\0"
126        reference_file.write_binary(data)
127        encoded_filename = ensure_binary(
128            str(reference_file), encoding=sys.getfilesystemencoding())
129
130        # 'reference_file' is a file object opened in binary mode
131        with reference_file.open(mode="rb") as f:
132            kwargs = {'in_buffer': b"\0", 'reference_file': f}
133            options = validate_options(kwargs)
134        assert options["reference_buffer"] == data
135        assert "reference_file" not in options
136        assert options["reference_buffer_len"] == len(data)
137        assert options["reference_name"] == encoded_filename
138
139        # 'reference_file' is a path string
140        kwargs = {'in_buffer': b"\0", 'reference_file': str(reference_file)}
141        options = validate_options(kwargs)
142        assert options["reference_buffer"] == data
143        assert "reference_file" not in options
144        assert options["reference_buffer_len"] == len(data)
145        assert options["reference_name"] == encoded_filename
146
147        # 'reference_file' is a file-like stream
148        kwargs = {'in_buffer': b"\0", 'reference_file': BytesIO(data)}
149        options = validate_options(kwargs)
150        assert options["reference_buffer"] == data
151        assert "reference_file" not in options
152        assert options["reference_buffer_len"] == len(data)
153        # the stream doesn't have a 'name' attribute, no reference_name
154        assert options["reference_name"] is None
155
156    def test_custom_reference_name(self, tmpdir):
157        reference_file = tmpdir / "font.ttf"
158        data = b"\0\1\0\0"
159        reference_file.write_binary(data)
160        expected = u"Some Font".encode(sys.getfilesystemencoding())
161
162        with reference_file.open(mode="rb") as f:
163            kwargs = {'in_buffer': b"\0",
164                      'reference_file': f,
165                      'reference_name': u"Some Font"}
166            options = validate_options(kwargs)
167
168        assert options["reference_name"] == expected
169
170        kwargs = {'in_buffer': b"\0",
171                  'reference_file': str(reference_file),
172                  'reference_name': u"Some Font"}
173        options = validate_options(kwargs)
174
175        assert options["reference_name"] == expected
176
177    def test_reference_buffer_is_bytes(self, tmpdir):
178        with pytest.raises(TypeError,
179                           match="reference_buffer type must be bytes"):
180            validate_options({"in_buffer": b"\0", "reference_buffer": u""})
181
182    def test_epoch(self):
183        options = validate_options({"in_buffer": b"\0", "epoch": 0})
184        assert isinstance(options["epoch"], c_ulonglong)
185        assert options["epoch"].value == 0
186
187    def test_family_suffix(self):
188        options = validate_options({"in_buffer": b"\0",
189                                    "family_suffix": b"-TA"})
190        assert isinstance(options["family_suffix"], text_type)
191        assert options["family_suffix"] == u"-TA"
192
193
194@pytest.mark.parametrize(
195    "options, expected",
196    [
197        (
198            {},
199            (b"", ())
200        ),
201        (
202            {
203                "in_buffer": b"\0\1\0\0",
204                "in_buffer_len": 4,
205                "out_buffer": None,
206                "out_buffer_len": None,
207                "error_string": None,
208                "alloc_func": None,
209                "free_func": None,
210                "info_callback": None,
211                "info_post_callback": None,
212                "progress_callback": None,
213                "progress_callback_data": None,
214                "error_callback": None,
215                "error_callback_data": None,
216                "control_buffer": b"abcd",
217                "control_buffer_len": 4,
218                "reference_buffer": b"\0\1\0\0",
219                "reference_buffer_len": 4,
220                "reference_index": 1,
221                "reference_name": b"/path/to/font.ttf",
222                "hinting_range_min": 8,
223                "hinting_range_max": 50,
224                "hinting_limit": 200,
225                "hint_composites": False,
226                "adjust_subglyphs": False,
227                "increase_x_height": 14,
228                "x_height_snapping_exceptions": b"6,15-18",
229                "windows_compatibility": True,
230                "default_script": b"grek",
231                "fallback_script": b"latn",
232                "fallback_scaling": False,
233                "symbol": True,
234                "fallback_stem_width": 100,
235                "ignore_restrictions": True,
236                "family_suffix": b"-Hinted",
237                "detailed_info": True,
238                "no_info": False,
239                "TTFA_info": True,
240                "dehint": False,
241                "epoch": 1513955869,
242                "debug": False,
243                "verbose": True,
244            },
245            ((b"TTFA-info, adjust-subglyphs, control-buffer, "
246              b"control-buffer-len, debug, default-script, dehint, "
247              b"detailed-info, epoch, fallback-scaling, fallback-script, "
248              b"fallback-stem-width, family-suffix, hint-composites, "
249              b"hinting-limit, hinting-range-max, hinting-range-min, "
250              b"ignore-restrictions, in-buffer, in-buffer-len, "
251              b"increase-x-height, no-info, reference-buffer, "
252              b"reference-buffer-len, reference-index, reference-name, "
253              b"symbol, verbose, windows-compatibility, "
254              b"x-height-snapping-exceptions"),
255             (True, False, b'abcd',
256              4, False, b'grek', False,
257              True, 1513955869, False, b'latn',
258              100, b'-Hinted', False,
259              200, 50, 8,
260              True, b'\x00\x01\x00\x00', 4,
261              14, False, b'\x00\x01\x00\x00',
262              4, 1, b'/path/to/font.ttf',
263              True, True, True,
264              b'6,15-18'))
265        ),
266        (
267            {"unkown_option": 1},
268            (b"", ())
269        )
270    ],
271    ids=[
272        "empty",
273        "full-options",
274        "unknown-option",
275    ]
276)
277def test_format_varargs(options, expected):
278    assert format_varargs(**options) == expected
279
280
281@pytest.mark.parametrize(
282    "string, expected",
283    [
284        (
285            "",
286            {
287                "gray_stem_width_mode": StemWidthMode.QUANTIZED,
288                "gdi_cleartype_stem_width_mode": StemWidthMode.QUANTIZED,
289                "dw_cleartype_stem_width_mode": StemWidthMode.QUANTIZED
290            }
291        ),
292        (
293            "g",
294            {
295                "gray_stem_width_mode": StemWidthMode.STRONG,
296                "gdi_cleartype_stem_width_mode": StemWidthMode.QUANTIZED,
297                "dw_cleartype_stem_width_mode": StemWidthMode.QUANTIZED
298            }
299        ),
300        (
301            "G",
302            {
303                "gray_stem_width_mode": StemWidthMode.QUANTIZED,
304                "gdi_cleartype_stem_width_mode": StemWidthMode.STRONG,
305                "dw_cleartype_stem_width_mode": StemWidthMode.QUANTIZED
306            }
307        ),
308        (
309            "D",
310            {
311                "gray_stem_width_mode": StemWidthMode.QUANTIZED,
312                "gdi_cleartype_stem_width_mode": StemWidthMode.QUANTIZED,
313                "dw_cleartype_stem_width_mode": StemWidthMode.STRONG
314            }
315        ),
316        (
317            "DGg",
318            {
319                "gray_stem_width_mode": StemWidthMode.STRONG,
320                "gdi_cleartype_stem_width_mode": StemWidthMode.STRONG,
321                "dw_cleartype_stem_width_mode": StemWidthMode.STRONG
322            }
323        ),
324    ],
325    ids=[
326        "empty-string",
327        "only-gray",
328        "only-gdi",
329        "only-dw",
330        "all"
331    ]
332)
333def test_strong_stem_width(string, expected):
334    assert strong_stem_width(string) == expected
335
336
337def test_strong_stem_width_invalid():
338    with pytest.raises(argparse.ArgumentTypeError,
339                       match="string can only contain up to 3 letters"):
340        strong_stem_width("GGGG")
341
342    with pytest.raises(argparse.ArgumentTypeError,
343                       match="invalid value: 'a'"):
344        strong_stem_width("a")
345
346
347@pytest.mark.parametrize(
348    "string, expected",
349    [
350        (
351            "nnn",
352            {
353                "gray_stem_width_mode": StemWidthMode.NATURAL,
354                "gdi_cleartype_stem_width_mode": StemWidthMode.NATURAL,
355                "dw_cleartype_stem_width_mode": StemWidthMode.NATURAL
356            }
357        ),
358        (
359            "qqq",
360            {
361                "gray_stem_width_mode": StemWidthMode.QUANTIZED,
362                "gdi_cleartype_stem_width_mode": StemWidthMode.QUANTIZED,
363                "dw_cleartype_stem_width_mode": StemWidthMode.QUANTIZED
364            }
365        ),
366        (
367            "sss",
368            {
369                "gray_stem_width_mode": StemWidthMode.STRONG,
370                "gdi_cleartype_stem_width_mode": StemWidthMode.STRONG,
371                "dw_cleartype_stem_width_mode": StemWidthMode.STRONG
372            }
373        ),
374        (
375            "nqs",
376            {
377                "gray_stem_width_mode": StemWidthMode.NATURAL,
378                "gdi_cleartype_stem_width_mode": StemWidthMode.QUANTIZED,
379                "dw_cleartype_stem_width_mode": StemWidthMode.STRONG
380            }
381        ),
382    ],
383    ids=["nnn", "qqq", "sss", "nqs"]
384)
385def test_stem_width_mode(string, expected):
386    assert stem_width_mode(string) == expected
387
388
389def test_stem_width_mode_invalid():
390    with pytest.raises(argparse.ArgumentTypeError,
391                       match="must consist of exactly three letters"):
392        stem_width_mode("nnnn")
393
394    with pytest.raises(argparse.ArgumentTypeError,
395                       match="Stem width mode letter for .* must be"):
396        stem_width_mode("zzz")
397
398
399@pytest.fixture(
400    params=[True, False],
401    ids=['tty', 'pipe'],
402)
403def isatty(request):
404    return request.param
405
406
407class MockFile(object):
408
409    def __init__(self, f, isatty):
410        self._file = f
411        self._isatty = isatty
412
413    def isatty(self):
414        return self._isatty
415
416    def __getattr__(self, attr):
417        return getattr(self._file, attr)
418
419
420def test_stdin_input_type(monkeypatch, tmpdir, isatty):
421    tmp = (tmpdir / "stdin").ensure().open("r")
422    monkeypatch.setattr(sys, "stdin", MockFile(tmp, isatty))
423
424    f = stdin_or_input_path_type("-")
425
426    if isatty:
427        assert f is None
428    else:
429        assert hasattr(f, "read")
430        assert f.mode == "rb"
431        assert f.closed is False
432
433
434def test_path_input_type(tmpdir):
435    tmp = tmpdir / "font.ttf"
436    s = str(tmp)
437    path = stdin_or_input_path_type(s)
438    assert path == s
439
440
441def test_stdout_output_type(monkeypatch, tmpdir, isatty):
442    tmp = (tmpdir / "stdout").open("w")
443    monkeypatch.setattr(sys, "stdout", MockFile(tmp, isatty))
444
445    f = stdout_or_output_path_type("-")
446
447    if isatty:
448        assert f is None
449    else:
450        assert hasattr(f, "write")
451        assert f.mode == "wb"
452        assert f.closed is False
453
454
455def test_path_output_type(tmpdir):
456    tmp = tmpdir / "font.ttf"
457    s = str(tmp)
458    path = stdout_or_output_path_type(s)
459    assert path == s
460
461
462class TestParseArgs(object):
463
464    argv0 = "python -m ttfautohint"
465
466    def test_unrecognized_arguments(self, monkeypatch, capsys):
467        monkeypatch.setattr(argparse._sys, "argv", [self.argv0, "--foo"])
468
469        with pytest.raises(SystemExit) as exc_info:
470            parse_args()
471
472        assert str(exc_info.value) == "2"
473        assert "unrecognized arguments: --foo" in capsys.readouterr()[1]
474
475        monkeypatch.undo()
476
477        assert parse_args("--bar") is None
478        assert "unrecognized arguments: --bar" in capsys.readouterr()[1]
479
480        assert parse_args(["--baz"]) is None
481        assert "unrecognized arguments: --baz" in capsys.readouterr()[1]
482
483    def test_no_in_file(self, monkeypatch, capsys):
484        monkeypatch.setattr(argparse._sys, "argv", [self.argv0])
485
486        with pytest.raises(SystemExit) as exc_info:
487            parse_args()
488
489        assert str(exc_info.value) == "1"
490
491        out, err = capsys.readouterr()
492        assert "usage: ttfautohint" in out
493        assert not err
494
495    def test_no_out_file(self, monkeypatch, capsys):
496        monkeypatch.setattr(argparse._sys, "argv", [self.argv0, "font.ttf"])
497
498        with pytest.raises(SystemExit) as exc_info:
499            parse_args()
500
501        assert str(exc_info.value) == "1"
502
503        out, err = capsys.readouterr()
504        assert "usage: ttfautohint" in out
505        assert not err
506
507    def test_source_date_epoch(self, monkeypatch):
508        epoch = "1513966552"
509        env = dict(os.environ)
510        env["SOURCE_DATE_EPOCH"] = epoch
511        monkeypatch.setattr(os, "environ", env)
512
513        options = parse_args([])
514
515        assert options["epoch"] == int(epoch)
516
517    def test_source_date_epoch_invalid(self, monkeypatch):
518        invalid_epoch = "foobar"
519        env = dict(os.environ)
520        env["SOURCE_DATE_EPOCH"] = invalid_epoch
521        monkeypatch.setattr(os, "environ", env)
522
523        with pytest.warns(UserWarning,
524                          match="invalid SOURCE_DATE_EPOCH: 'foobar'"):
525            options = parse_args([])
526
527        assert "epoch" not in options
528
529    def test_show_ttfa_info_unsupported(self):
530        with pytest.raises(NotImplementedError):
531            parse_args("-T")
532