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