commit 034d5fbcb726185742d6f5f0876bfbd6b0bcf8fc Author: Jan Petykiewicz Date: Sat Sep 26 17:55:17 2020 -0700 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e85485d --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# klamath README + +`klamath` is a Python module for reading and writing to the GDSII file format. + +The goal is to keep this library simple: +- Map data types directly wherever possible. + * Presents an accurate representation of what is saved to the file. + * Avoids excess copies / allocations for speed. + * No "automatic" error checking, except when casting datatypes. + If data integrity checks are provided at all, they must be + explicitly run by the caller. +- Low-level functionality is first-class. + * Meant for use-cases where the caller wants to read or write + individual GDS records. + * Offers complete control over the written file. +- Opinionated and limited high-level functionality. + * Discards or ignores rarely-encountered data types. + * Keeps functions simple and reusable. + * Only de/encodes the file format, doesn't provide tools to modify + the data itself. + * Still requires explicit values for most fields. +- No compilation + * Uses `numpy` for speed, since it's commonly available / pre-built. + * Building this library should not require a compiler. + +`klamath` was built to provide a fast and versatile GDS interface for + [masque](https://mpxd.net/code/jan/masque), which provides higher-level + tools for working with hierarchical design data and supports multiple + file formats. + + +### Alternatives +- [gdspy](https://github.com/heitzmann/gdspy) + * Provides abstractions and methods for working with design data + outside of the I/O process (e.g. polygon clipping). + * Requires compilation (C++) to build from source. + * Focused on high-level API +- [python-gdsii](https://pypi.org/project/python-gdsii) + * Pure-python implementation. Can easily be altered to use `numpy` + for speed, but is limited by object allocation overhead. + * Focused on high-level API + + +### Links +- [Source repository](https://mpxd.net/code/jan/klamath) +- [PyPI](https://pypi.org/project/klamath) + + +## Installation + +Requirements: +* python >= 3.7 (written and tested with 3.8) +* numpy + + +Install with pip: +```bash +pip3 install klamath +``` + +Alternatively, install from git +```bash +pip3 install git+https://mpxd.net/code/jan/klamath.git@release +``` + +## Examples +### Low-level + +Filter which polygons are read based on layer: + +```python3 +import io +import klamath +from klamath import records +from klamath.record import Record + +def read_polygons(stream, filter_layer_tuple=(4, 5)): + """ + Given a stream positioned at the start of a record, + return the vertices of all BOUNDARY records which match + the provided `filter_layer_tuple`, up to the next + ENDSTR record. + """ + polys = [] + while True: + size, tag = Record.read_header(stream) + stream.seek(size, io.SEEK_CUR) # skip to next header + + if tag == records.ENDEL.tag: + break # If ENDEL, we are done + + if tag != records.BOUNDARY.tag: + continue # Skip until we find a BOUNDARY + + layer = records.LAYER.skip_and_read(stream)[0] # skip to LAYER + dtype = records.DATATYPE.read(stream)[0] + + if (layer, dtype) != filter_layer_tuple: + continue # Skip reading XY unless layer matches + + xy = XY.read(stream).reshape(-1, 2) + polys.append(xy) + return polys +``` + +### High-level + +Write an example GDS file: + +```python3 +import klamath +from klamath.elements import Boundary, Text, Path, Reference + +stream = file.open('example.gds', 'wb') + +header = klamath.library.FileHeader( + name=b'example', + meters_per_db_unit=1e-9, # 1 nm DB unit + user_units_per_db_unit=1e-3) # 1 um (1000nm) display unit +header.write(stream) + +elements_A = [ + Boundary(layer=(4, 18), + xy=[[0, 0], [10, 0], [10, 20], [0, 20], [0, 0]], + properties={1: b'prop1string', 2: b'some other string'}), + Text(layer=(5, 5), + xy=[[5, 10]], + string=b'center position', + properties={}, # Remaining args are set to default values + presentation=0, # and will be omitted when writing + angle_deg=0, + invert_y=False, + width=0, + path_type=0, + mag=1), + Path(layer=(4, 20), + xy=[[0, 0], [10, 10], [0, 20]], + path_type=0, + width=0, + extension=(0, 0), # ignored since path_type=0 + properties={}), + ] +klamath.library.write(stream, name=b'my_struct', elements=elements_A) + +elements_top = [ + Reference(struct_name=b'my_struct', + xy=[[30, 30]], + colrow=None, # not an array + angle_deg=0, + invert_y=True, + mag=1.5, + properties={}), + Reference(struct_name=b'my_struct', + colrow=(3, 2), # 3x2 array at (0, 50) + xy=[[0, 50], [60, 50], [30, 50]] # with basis vectors + angle_deg=30, # [20, 0] and [0, 30] + invert_y=False, + mag=1, + properties={}), + ] +klamath.library.write(stream, name=b'top', elements=elements_top) + +klamath.records.ENDLIB.write(stream) +stream.close() +``` + +Read back the file: + +```python3 +import klamath + +stream = file.open('example.gds', 'rb') +header = klamath.library.FileHeader.read(stream) + +structs = {} + +struct = klamath.library.try_read_struct(stream) +while struct is not None: + name, elements = struct + structs[name] = elements + struct = klamath.library.try_read_struct(stream) + +stream.close() +``` + +Read back a single struct by name: + +```python3 +import klamath + +stream = file.open('example.gds', 'rb') + +header = klamath.library.FileHeader.read(stream) +struct_positions = klamath.library.scan_structs(stream) + +stream.seek(struct_positions[b'my_struct']) +elements_A = klamath.library.try_read_struct(stream) + +stream.close() +``` diff --git a/klamath/VERSION b/klamath/VERSION new file mode 100644 index 0000000..49d5957 --- /dev/null +++ b/klamath/VERSION @@ -0,0 +1 @@ +0.1 diff --git a/klamath/__init__.py b/klamath/__init__.py new file mode 100644 index 0000000..9f48837 --- /dev/null +++ b/klamath/__init__.py @@ -0,0 +1,42 @@ +""" +`klamath` is a Python module for reading and writing to the GDSII file format. + +The goal is to keep this library simple: +- Map data types directly wherever possible. + * Presents an accurate representation of what is saved to the file. + * Avoids excess copies / allocations for speed. + * No "automatic" error checking, except when casting datatypes. + If data integrity checks are provided at all, they must be + explicitly run by the caller. +- Low-level functionality is first-class. + * Meant for use-cases where the caller wants to read or write + individual GDS records. + * Offers complete control over the written file. +- Opinionated and limited high-level functionality. + * Discards or ignores rarely-encountered data types. + * Keeps functions simple and reusable. + * Only de/encodes the file format, doesn't provide tools to modify + the data itself. + * Still requires explicit values for most fields. +- No compilation + * Uses `numpy` for speed, since it's commonly available / pre-built. + * Building this library should not require a compiler. + +`klamath` was built to provide a fast and versatile GDS interface for + [masque](https://mpxd.net/code/jan/masque), which provides higher-level + tools for working with hierarchical design data and supports multiple + file formats. +""" +import pathlib + +from . import basic +from . import record +from . import records +from . import elements +from . import library + + +__author__ = 'Jan Petykiewicz' + +with open(pathlib.Path(__file__).parent / 'VERSION', 'r') as f: + __version__ = f.read().strip() diff --git a/klamath/__pycache__/__init__.cpython-38.pyc b/klamath/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..b9e48f7 Binary files /dev/null and b/klamath/__pycache__/__init__.cpython-38.pyc differ diff --git a/klamath/__pycache__/basic.cpython-38.pyc b/klamath/__pycache__/basic.cpython-38.pyc new file mode 100644 index 0000000..6e8de97 Binary files /dev/null and b/klamath/__pycache__/basic.cpython-38.pyc differ diff --git a/klamath/__pycache__/elements.cpython-38.pyc b/klamath/__pycache__/elements.cpython-38.pyc new file mode 100644 index 0000000..a841118 Binary files /dev/null and b/klamath/__pycache__/elements.cpython-38.pyc differ diff --git a/klamath/__pycache__/library.cpython-38.pyc b/klamath/__pycache__/library.cpython-38.pyc new file mode 100644 index 0000000..71a72fb Binary files /dev/null and b/klamath/__pycache__/library.cpython-38.pyc differ diff --git a/klamath/__pycache__/record.cpython-38.pyc b/klamath/__pycache__/record.cpython-38.pyc new file mode 100644 index 0000000..30e6290 Binary files /dev/null and b/klamath/__pycache__/record.cpython-38.pyc differ diff --git a/klamath/__pycache__/records.cpython-38.pyc b/klamath/__pycache__/records.cpython-38.pyc new file mode 100644 index 0000000..591fc6a Binary files /dev/null and b/klamath/__pycache__/records.cpython-38.pyc differ diff --git a/klamath/__pycache__/test_basic.cpython-38-PYTEST.pyc b/klamath/__pycache__/test_basic.cpython-38-PYTEST.pyc new file mode 100644 index 0000000..9d4d973 Binary files /dev/null and b/klamath/__pycache__/test_basic.cpython-38-PYTEST.pyc differ diff --git a/klamath/__pycache__/test_record.cpython-38-PYTEST.pyc b/klamath/__pycache__/test_record.cpython-38-PYTEST.pyc new file mode 100644 index 0000000..0a4d0de Binary files /dev/null and b/klamath/__pycache__/test_record.cpython-38-PYTEST.pyc differ diff --git a/klamath/basic.py b/klamath/basic.py new file mode 100644 index 0000000..5ea482e --- /dev/null +++ b/klamath/basic.py @@ -0,0 +1,174 @@ +""" +Functionality for encoding/decoding basic datatypes +""" +from typing import Sequence, BinaryIO, List +import struct +from datetime import datetime + +import numpy # type: ignore + + +class KlamathError(Exception): + pass + + +""" +Parse functions +""" +def parse_bitarray(data: bytes) -> int: + if len(data) != 2: + raise KlamathError(f'Incorrect bitarray size ({len(data)}). Data is {data!r}.') + (val,) = struct.unpack('>H', data) + return val + + +def parse_int2(data: bytes) -> numpy.ndarray: + data_len = len(data) + if data_len == 0 or (data_len % 2) != 0: + raise KlamathError(f'Incorrect int2 size ({len(data)}). Data is {data!r}.') + return numpy.frombuffer(data, dtype='>i2', count=data_len // 2) + + +def parse_int4(data: bytes) -> numpy.ndarray: + data_len = len(data) + if data_len == 0 or (data_len % 4) != 0: + raise KlamathError(f'Incorrect int4 size ({len(data)}). Data is {data!r}.') + return numpy.frombuffer(data, dtype='>i4', count=data_len // 4) + + +def decode_real8(nums: numpy.ndarray) -> numpy.ndarray: + """ Convert GDS REAL8 data to IEEE float64. """ + nums = nums.astype(numpy.uint64) + neg = nums & 0x8000_0000_0000_0000 + exp = (nums >> 56) & 0x7f + mant = (nums & 0x00ff_ffff_ffff_ffff).astype(numpy.float64) + mant[neg != 0] *= -1 + return numpy.ldexp(mant, 4 * (exp - 64) - 56, dtype=numpy.float64) + + +def parse_real8(data: bytes) -> numpy.ndarray: + data_len = len(data) + if data_len == 0 or (data_len % 8) != 0: + raise KlamathError(f'Incorrect real8 size ({len(data)}). Data is {data!r}.') + ints = numpy.frombuffer(data, dtype='>u8', count=data_len // 8) + return decode_real8(ints) + + +def parse_ascii(data: bytes) -> bytes: + if len(data) == 0: + raise KlamathError(f'Received empty ascii data.') + if data[-1:] == b'\0': + return data[:-1] + return data + + +def parse_datetime(data: bytes) -> List[datetime]: + """ Parse date/time data (12 byte blocks) """ + if len(data) == 0 or len(data) % 12 != 0: + raise KlamathError(f'Incorrect datetime size ({len(data)}). Data is {data!r}.') + dts = [] + for ii in range(0, len(data), 12): + year, *date_parts = parse_int2(data[ii:ii+12]) + dts.append(datetime(year + 1900, *date_parts)) + return dts + + +""" +Pack functions +""" +def pack_bitarray(data: int) -> bytes: + if data > 65535 or data < 0: + raise KlamathError(f'bitarray data out of range: {data}') + return struct.pack('>H', data) + + +def pack_int2(data: Sequence[int]) -> bytes: + arr = numpy.array(data) + if (arr > 32767).any() or (arr < -32768).any(): + raise KlamathError(f'int2 data out of range: {arr}') + return arr.astype('>i2').tobytes() + + +def pack_int4(data: Sequence[int]) -> bytes: + arr = numpy.array(data) + if (arr > 2147483647).any() or (arr < -2147483648).any(): + raise KlamathError(f'int4 data out of range: {arr}') + return arr.astype('>i4').tobytes() + + +def encode_real8(fnums: numpy.ndarray) -> numpy.ndarray: + """ Convert from float64 to GDS REAL8 representation. """ + # Split the bitfields + ieee = numpy.atleast_1d(fnums.astype(numpy.float64).view(numpy.uint64)) + sign = ieee & numpy.uint64(0x8000_0000_0000_0000) + ieee_exp = (ieee >> numpy.uint64(52)).astype(numpy.int32) & numpy.int32(0x7ff) + ieee_mant = ieee & numpy.uint64(0xf_ffff_ffff_ffff) + + subnorm = (ieee_exp == 0) & (ieee_mant != 0) + zero = (ieee_exp == 0) & (ieee_mant == 0) + + # Convert exponent. + # * 16-based + # * +1 due to mantissa differences (1.xxxx in IEEE vs 0.1xxxxx in GDSII) + exp16, rest = numpy.divmod(ieee_exp + 1 - 1023, 4) + # Compensate exponent conversion + comp = (rest != 0) + exp16[comp] += 1 + + shift = rest.copy().astype(numpy.int8) + shift[comp] = 4 - rest[comp] + shift -= 3 # account for gds bit position + + # add leading one + gds_mant_unshifted = ieee_mant + 0x10_0000_0000_0000 + + rshift = (shift > 0) + gds_mant = numpy.empty_like(ieee_mant) + gds_mant[~rshift] = gds_mant_unshifted[~rshift] << (-shift[~rshift]).astype(numpy.uint8) + gds_mant[ rshift] = gds_mant_unshifted[ rshift] >> ( shift[ rshift]).astype(numpy.uint8) + + # add gds exponent bias + exp16_biased = exp16 + 64 + + neg_biased = (exp16_biased < 0) + gds_mant[neg_biased] >>= (exp16_biased[neg_biased] * 4).astype(numpy.uint8) + exp16_biased[neg_biased] = 0 + + too_big = (exp16_biased > 0x7f) & ~(zero | subnorm) + if too_big.any(): + raise KlamathError(f'Number(s) too big for real8 format: {fnums[too_big]}') + + gds_exp = exp16_biased.astype(numpy.uint64) << 56 + + real8 = sign | gds_exp | gds_mant + real8[zero] = 0 + real8[subnorm] = 0 # TODO handle subnormals + real8[exp16_biased < -14] = 0 # number is too small + + return real8 + + +def pack_real8(data: Sequence[float]) -> bytes: + return encode_real8(numpy.array(data)).astype('>u8').tobytes() + + +def pack_ascii(data: bytes) -> bytes: + size = len(data) + if size % 2 != 0: + return data + b'\0' + return data + + +def pack_datetime(data: Sequence[datetime]) -> bytes: + """ Pack date/time data (12 byte blocks) """ + parts = sum(((d.year - 1900, d.month, d.day, d.hour, d.minute, d.second) + for d in data), start=()) + return pack_int2(parts) + + +def read(stream: BinaryIO, size: int) -> bytes: + """ Read and check for failure """ + data = stream.read(size) + if len(data) != size: + raise EOFError + return data diff --git a/klamath/elements.py b/klamath/elements.py new file mode 100644 index 0000000..5193921 --- /dev/null +++ b/klamath/elements.py @@ -0,0 +1,489 @@ +""" +Functionality for reading/writing elements (geometry, text labels, + structure references) and associated properties. +""" +from typing import Dict, Tuple, Optional, BinaryIO, TypeVar, Type +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass + +import numpy # type: ignore + +from .basic import KlamathError +from .record import Record + +from .records import BOX, BOUNDARY, NODE, PATH, TEXT, SREF, AREF +from .records import DATATYPE, PATHTYPE, BOXTYPE, NODETYPE, TEXTTYPE +from .records import LAYER, XY, WIDTH, COLROW, PRESENTATION, STRING +from .records import STRANS, MAG, ANGLE, PROPATTR, PROPVALUE +from .records import ENDEL, BGNEXTN, ENDEXTN, SNAME + + +E = TypeVar('E', bound='Element') + +R = TypeVar('R', bound='Reference') +B = TypeVar('B', bound='Boundary') +P = TypeVar('P', bound='Path') +N = TypeVar('N', bound='Node') +T = TypeVar('T', bound='Text') +X = TypeVar('X', bound='Box') + + + +def read_properties(stream: BinaryIO) -> Dict[int, bytes]: + """ + Read element properties. + + Assumes PROPATTR records have unique values. + Stops reading after consuming ENDEL record. + + Args: + stream: Stream to read from. + + Returns: + {propattr: b'propvalue'} mapping. + """ + properties = {} + + size, tag = Record.read_header(stream) + while tag != ENDEL.tag: + if tag == PROPATTR.tag: + key = PROPATTR.read_data(stream, size)[0] + value = PROPVALUE.read(stream) + if key in properties: + raise KlamathError(f'Duplicate property key: {key!r}') + properties[key] = value + size, tag = Record.read_header(stream) + return properties + + +def write_properties(stream: BinaryIO, properties: Dict[int, bytes]) -> int: + """ + Write element properties. + + This is does _not_ write the ENDEL record. + + Args: + stream: Stream to write to. + """ + b = 0 + for key, value in properties.items(): + b += PROPATTR.write(stream, key) + b += PROPVALUE.write(stream, value) + return b + + +class Element(metaclass=ABCMeta): + """ + Abstract method definition for GDS structure contents + """ + @classmethod + @abstractmethod + def read(cls: Type[E], stream: BinaryIO) -> E: + """ + Read from a stream to construct this object. + Consumes up to (and including) the ENDEL record. + + Args: + Stream to read from. + + Returns: + Constructed object. + """ + pass + + @abstractmethod + def write(self, stream: BinaryIO) -> int: + """ + Write this element to a stream. + Finishes with an ENDEL record. + + Args: + Stream to write to. + + Returns: + Number of bytes written + """ + pass + + +@dataclass +class Reference(Element): + """ + Datastructure representing + an instance of a structure (SREF / structure reference) or + an array of instances (AREF / array reference). + Type is determined by the presence of the `colrow` tuple. + + Transforms are applied to each individual instance (_not_ + to the instance's origin location or array vectors). + """ + __slots__ = ('struct_name', 'invert_y', 'mag', 'angle_deg', 'xy', 'colrow', 'properties') + + struct_name: bytes + """ Name of the structure being referenced. """ + + invert_y: bool + """ Whether to mirror the pattern (negate y-values / flip across x-axis). Default False. """ + + mag: float + """ Scaling factor (default 1) """ + + angle_deg: float + """ Rotation (degrees counterclockwise) """ + + xy: numpy.ndarray + """ + (For SREF) Location in the parent structure corresponding to the instance's origin (0, 0). + (For AREF) 3 locations: + [`offset`, + `offset + col_basis_vector * colrow[0]`, + `offset + row_basis_vector * colrow[1]`] + which define the first instance's offset and the array's basis vectors. + Note that many GDS implementations only support manhattan basis vectors, and some + assume a certain axis mapping (e.g. x->columns, y->rows) and "reinterpret" the + basis vectors to match it. + """ + + colrow: Optional[Tuple[int, int]] + """ Number of columns and rows (AREF) or None (SREF) """ + + properties: Dict[int, bytes] + """ Properties associated with this reference. """ + + @classmethod + def read(cls: Type[R], stream: BinaryIO) -> R: + invert_y = False + mag = 1 + angle_deg = 0 + colrow = None + struct_name = SNAME.skip_and_read(stream) + + size, tag = Record.read_header(stream) + while tag != XY.tag: + if tag == STRANS.tag: + strans = STRANS.read_data(stream, size) + invert_y = bool(0x8000 & strans) + elif tag == MAG.tag: + mag = MAG.read_data(stream, size)[0] + elif tag == ANGLE.tag: + angle_deg = ANGLE.read_data(stream, size)[0] + elif tag == COLROW.tag: + colrow = COLROW.read_data(stream, size) + else: + raise KlamathError(f'Unexpected tag {tag:04x}') + size, tag = Record.read_header(stream) + xy = XY.read_data(stream, size).reshape(-1, 2) + properties = read_properties(stream) + return cls(struct_name=struct_name, xy=xy, properties=properties, colrow=colrow, + invert_y=invert_y, mag=mag, angle_deg=angle_deg) + + def write(self, stream: BinaryIO) -> int: + b = 0 + if self.colrow is None: + b += SREF.write(stream, None) + else: + b += AREF.write(stream, None) + + b += SNAME.write(stream, self.struct_name) + if self.angle_deg != 0 or self.mag != 1 or self.invert_y: + b += STRANS.write(stream, int(self.invert_y) << 15) + if self.mag != 1: + b += MAG.write(stream, self.mag) + if self.angle_deg !=0: + b += ANGLE.write(stream, self.angle_deg) + + if self.colrow is not None: + b += COLROW.write(stream, self.colrow) + + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b + + def check(self) -> None: + if self.colrow is not None: + if self.xy.size != 6: + raise KlamathError(f'colrow is not None, so expected size-6 xy. Got {self.xy}') + else: + if self.xy.size != 2: + raise KlamathError(f'Expected size-2 xy. Got {self.xy}') + + +@dataclass +class Boundary(Element): + """ + Datastructure representing a Boundary element. + """ + __slots__ = ('layer', 'xy', 'properties') + + layer: Tuple[int, int] + """ (layer, data_type) tuple """ + + xy: numpy.ndarray + """ Ordered vertices of the shape. First and last points should be identical. """ + + properties: Dict[int, bytes] + """ Properties for the element. """ + + @classmethod + def read(cls: Type[B], stream: BinaryIO) -> B: + layer = LAYER.skip_and_read(stream)[0] + dtype = DATATYPE.read(stream)[0] + xy = XY.read(stream).reshape(-1, 2) + properties = read_properties(stream) + return cls(layer=(layer, dtype), xy=xy, properties=properties) + + def write(self, stream: BinaryIO) -> int: + b = BOUNDARY.write(stream, None) + b += LAYER.write(stream, self.layer[0]) + b += DATATYPE.write(stream, self.layer[1]) + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b + + +@dataclass +class Path(Element): + """ + Datastructure representing a Path element. + + If `path_type < 4`, `extension` values are not written. + During read, `exension` defaults to (0, 0) even if unused. + """ + __slots__ = ('layer', 'xy', 'properties', 'path_type', 'width', 'extension') + + layer: Tuple[int, int] + """ (layer, data_type) tuple """ + + path_type: int + """ End-cap type (0: flush, 1: circle, 2: square, 4: custom) """ + + width: int + """ Path width """ + + extension: Tuple[int, int] + """ Extension when using path_type=4. Ignored otherwise. """ + + xy: numpy.ndarray + """ Path centerline coordinates """ + + properties: Dict[int, bytes] + """ Properties for the element. """ + + @classmethod + def read(cls: Type[P], stream: BinaryIO) -> P: + path_type = 0 + width = 0 + bgn_ext = 0 + end_ext = 0 + layer = LAYER.skip_and_read(stream)[0] + dtype = DATATYPE.read(stream)[0] + + size, tag = Record.read_header(stream) + while tag != XY.tag: + if tag == PATHTYPE.tag: + path_type = PATHTYPE.read_data(stream, size)[0] + elif tag == WIDTH.tag: + width = WIDTH.read_data(stream, size)[0] + elif tag == BGNEXTN.tag: + bgn_ext = BGNEXTN.read_data(stream, size)[0] + elif tag == ENDEXTN.tag: + end_ext = ENDEXTN.read_data(stream, size)[0] + else: + raise KlamathError(f'Unexpected tag {tag:04x}') + size, tag = Record.read_header(stream) + xy = XY.read_data(stream, size).reshape(-1, 2) + properties = read_properties(stream) + return cls(layer=(layer, dtype), xy=xy, + properties=properties, extension=(bgn_ext, end_ext), + path_type=path_type, width=width) + + def write(self, stream: BinaryIO) -> int: + b = PATH.write(stream, None) + b += LAYER.write(stream, self.layer[0]) + b += DATATYPE.write(stream, self.layer[1]) + if self.path_type != 0: + b += PATHTYPE.write(stream, self.path_type) + if self.width != 0: + b += WIDTH.write(stream, self.width) + + if self.path_type < 4: + bgn_ext, end_ext = self.extension + if bgn_ext != 0: + b += BGNEXTN.write(stream, bgn_ext) + if end_ext != 0: + b += ENDEXTN.write(stream, end_ext) + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b + + +@dataclass +class Box(Element): + """ + Datastructure representing a Box element. Rarely used. + """ + __slots__ = ('layer', 'xy', 'properties') + + layer: Tuple[int, int] + """ (layer, box_type) tuple """ + + xy: numpy.ndarray + """ Box coordinates (5 pairs) """ + + properties: Dict[int, bytes] + """ Properties for the element. """ + + @classmethod + def read(cls: Type[X], stream: BinaryIO) -> X: + layer = LAYER.skip_and_read(stream) + dtype = BOXTYPE.read(stream) + xy = XY.read(stream).reshape(-1, 2) + properties = read_properties(stream) + return cls(layer=(layer, dtype), xy=xy, properties=properties) + + def write(self, stream: BinaryIO) -> int: + b = BOX.write(stream, None) + b += LAYER.write(stream, self.layer[0]) + b += BOXTYPE.write(stream, self.layer[1]) + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b + + +@dataclass +class Node(Element): + """ + Datastructure representing a Node element. Rarely used. + """ + __slots__ = ('layer', 'xy', 'properties') + + layer: Tuple[int, int] + """ (layer, node_type) tuple """ + + xy: numpy.ndarray + """ 1-50 pairs of coordinates. """ + + properties: Dict[int, bytes] + """ Properties for the element. """ + + @classmethod + def read(cls: Type[N], stream: BinaryIO) -> N: + layer = LAYER.skip_and_read(stream) + dtype = NODETYPE.read(stream) + xy = XY.read(stream).reshape(-1, 2) + properties = read_properties(stream) + return cls(layer=(layer, dtype), xy=xy, properties=properties) + + def write(self, stream: BinaryIO) -> int: + b = NODE.write(stream, None) + b += LAYER.write(stream, self.layer[0]) + b += NODETYPE.write(stream, self.layer[1]) + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b + + +@dataclass +class Text(Element): + """ + Datastructure representing a Node element. Rarely used. + """ + __slots__ = ('layer', 'xy', 'properties', 'presentation', 'path_type', + 'width', 'invert_y', 'mag', 'angle_deg', 'string') + + layer: Tuple[int, int] + """ (layer, node_type) tuple """ + + presentation: int + """ Bit array. Default all zeros. + bits 0-1: 00 left/01 center/10 right + bits 2-3: 00 top/01 middle/10 bottom + bits 4-5: font number + """ + + path_type: int + """ Default 0 """ + + width: int + """ Default 0 """ + + invert_y: bool + """ Vertical inversion. Default False. """ + + mag: float + """ Scaling factor. Default 1. """ + + angle_deg: float + """ Rotation (ccw). Default 0. """ + + xy: numpy.ndarray + """ Position (1 pair only) """ + + string: bytes + """ Text content """ + + properties: Dict[int, bytes] + """ Properties for the element. """ + + @classmethod + def read(cls: Type[T], stream: BinaryIO) -> T: + path_type = 0 + presentation = 0 + invert_y = False + width = 0 + mag = 1 + angle_deg = 0 + layer = LAYER.skip_and_read(stream) + dtype = TEXTTYPE.read(stream) + + size, tag = Record.read_header(stream) + while tag != XY.tag: + if tag == PRESENTATION.tag: + presentation = PRESENTATION.read_data(stream, size) + elif tag == PATHTYPE.tag: + path_type = PATHTYPE.read_data(stream, size)[0] + elif tag == WIDTH.tag: + width = WIDTH.read_data(stream, size)[0] + elif tag == STRANS.tag: + strans = STRANS.read_data(stream, size) + invert_y = bool(0x8000 & strans) + elif tag == MAG.tag: + mag = MAG.read_data(stream, size)[0] + elif tag == ANGLE.tag: + angle_deg = ANGLE.read_data(stream, size)[0] + else: + raise KlamathError(f'Unexpected tag {tag:04x}') + size, tag = Record.read_header(stream) + xy = XY.read_data(stream, size).reshape(-1, 2) + + string = STRING.read(stream) + properties = read_properties(stream) + return cls(layer=(layer, dtype), xy=xy, properties=properties, + string=string, presentation=presentation, path_type=path_type, + width=width, invert_y=invert_y, mag=mag, angle_deg=angle_deg) + + def write(self, stream: BinaryIO) -> int: + b = TEXT.write(stream, None) + b += LAYER.write(stream, self.layer[0]) + b += TEXTTYPE.write(stream, self.layer[1]) + if self.presentation != 0: + b += PRESENTATION.write(stream, self.presentation) + if self.path_type != 0: + b += PATHTYPE.write(stream, self.path_type) + if self.width != 0: + b += WIDTH.write(stream, self.width) + if self.angle_deg != 0 or self.mag != 1 or self.invert_y: + b += STRANS.write(stream, int(self.invert_y) << 15) + if self.mag != 1: + b += MAG.write(stream, self.mag) + if self.angle_deg !=0: + b += ANGLE.write(stream, self.angle_deg) + b += XY.write(stream, self.xy) + b += write_properties(stream, self.properties) + b += ENDEL.write(stream, None) + return b diff --git a/klamath/library.py b/klamath/library.py new file mode 100644 index 0000000..f85b517 --- /dev/null +++ b/klamath/library.py @@ -0,0 +1,187 @@ +""" +File-level read/write functionality. +""" +from typing import List, Dict, Tuple, Optional, BinaryIO, TypeVar, Type +import io +from datetime import datetime +from dataclasses import dataclass + +from .basic import KlamathError +from .record import Record + +from .records import HEADER, BGNLIB, ENDLIB, UNITS, LIBNAME +from .records import BGNSTR, STRNAME, ENDSTR +from .records import BOX, BOUNDARY, NODE, PATH, TEXT, SREF, AREF +from .elements import Element, Reference, Text, Box, Boundary, Path, Node + + +FH = TypeVar('FH', bound='FileHeader') + + +@dataclass +class FileHeader: + """ + Representation of the GDS file header. + + File header records: HEADER BGNLIB LIBNAME UNITS + Optional record are ignored if present and never written. + + Version is assumed to be `600`. + """ + name: bytes + """ Library name """ + + user_units_per_db_unit: float + """ Number of user units in one database unit """ + + meters_per_db_unit: float + """ Number of meters in one database unit """ + + mod_time: datetime = datetime(1900, 1, 1) + """ Last-modified time """ + + acc_time: datetime = datetime(1900, 1, 1) + """ Last-accessed time """ + + @classmethod + def read(cls: Type[FH], stream: BinaryIO) -> FH: + """ + Read and construct a header from the provided stream. + + Args: + stream: Seekable stream to read from + + Returns: + FileHeader object + """ + version = HEADER.read(stream)[0] + if version != 600: + raise KlamathError(f'Got GDS version {version}, expected 600') + mod_time, acc_time = BGNLIB.read(stream) + name = LIBNAME.skip_and_read(stream) + uu, dbu = UNITS.skip_and_read(stream) + + return cls(mod_time=mod_time, acc_time=acc_time, name=name, + user_units_per_db_unit=uu, meters_per_db_unit=dbu) + + def write(self, stream: BinaryIO) -> int: + """ + Write the header to a stream + + Args: + stream: Stream to write to + + Returns: + number of bytes written + """ + b = HEADER.write(stream, 600) + b += BGNLIB.write(stream, (self.mod_time, self.acc_time)) + b += LIBNAME.write(stream, self.name) + b += UNITS.write(stream, (self.user_units_per_db_unit, self.meters_per_db_unit)) + return b + + +def scan_structs(stream: BinaryIO) -> Dict[bytes, int]: + """ + Scan through a GDS file, building a table of + {b'structure_name': byte_offset}. + The intent of this function is to enable random access + and/or partial (structure-by-structure) reads. + + Args: + stream: Seekable stream to read from. Should be positioned + before the first structure record, but possibly + already past the file header. + """ + positions = {} + + size, tag = Record.read_header(stream) + while tag != ENDLIB.tag: + stream.seek(size, io.SEEK_CUR) + if tag == BGNSTR.tag: + name = STRNAME.read(stream) + if name in positions: + raise KlamathError(f'Duplicate structure name: {name!r}') + positions[name] = stream.tell() + size, tag = Record.read_header(stream) + + return positions + + +def try_read_struct(stream: BinaryIO) -> Optional[Tuple[bytes, List[Element]]]: + """ + Skip to the next structure and attempt to read it. + + Args: + stream: Seekable stream to read from. + + Returns: + (name, elements) if a structure was found. + None if no structure was found before the end of the library. + """ + if not BGNSTR.skip_past(stream): + return None + name = STRNAME.read(stream) + elements = read_elements(stream) + return name, elements + + +def write_struct(stream: BinaryIO, + name: bytes, + elements: List[Element], + cre_time: datetime = datetime(1900, 1, 1), + mod_time: datetime = datetime(1900, 1, 1), + ) -> int: + """ + Write a structure to the provided stream. + + Args: + name: Structure name (ascii-encoded). + elements: List of Elements containing the geometry and text in this struct. + cre_time: Creation time (optional). + mod_time: Modification time (optional). + + Return: + Number of bytes written + """ + b = BGNSTR.write(stream, (cre_time, mod_time)) + b += STRNAME.write(stream, name) + b += sum(el.write(stream) for el in elements) + b += ENDSTR.write(stream, None) + return b + + +def read_elements(stream: BinaryIO) -> List[Element]: + """ + Read elements from the stream until an ENDSTR + record is encountered. The ENDSTR record is also + consumed. + + Args: + stream: Seekable stream to read from. + + Returns: + List of element objects. + """ + data: List[Element] = [] + size, tag = Record.read_header(stream) + while tag != ENDSTR.tag: + if tag == BOUNDARY.tag: + data.append(Boundary.read(stream)) + elif tag == PATH.tag: + data.append(Path.read(stream)) + elif tag == NODE.tag: + data.append(Node.read(stream)) + elif tag == BOX.tag: + data.append(Box.read(stream)) + elif tag == TEXT.tag: + data.append(Text.read(stream)) + elif tag == SREF.tag: + data.append(Reference.read(stream)) + elif tag == AREF.tag: + data.append(Reference.read(stream)) + else: + # don't care, skip + stream.seek(size, io.SEEK_CUR) + size, tag = Record.read_header(stream) + return data diff --git a/klamath/py.typed b/klamath/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/klamath/record.py b/klamath/record.py new file mode 100644 index 0000000..0f0777f --- /dev/null +++ b/klamath/record.py @@ -0,0 +1,208 @@ +""" +Generic record-level read/write functionality. +""" +from typing import Optional, Sequence, BinaryIO +from typing import TypeVar, List, Tuple, ClassVar, Type +import struct +import io +from datetime import datetime +from abc import ABCMeta, abstractmethod + +import numpy # type: ignore + +from .basic import KlamathError +from .basic import parse_int2, parse_int4, parse_real8, parse_datetime, parse_bitarray +from .basic import pack_int2, pack_int4, pack_real8, pack_datetime, pack_bitarray +from .basic import parse_ascii, pack_ascii, read + + +_RECORD_HEADER_FMT = struct.Struct('>HH') + + +def write_record_header(stream: BinaryIO, data_size: int, tag: int) -> int: + record_size = data_size + 4 + if record_size > 0xFFFF: + raise KlamathError(f'Record size is too big: {record_size}') + header = _RECORD_HEADER_FMT.pack(record_size, tag) + return stream.write(header) + + +def read_record_header(stream: BinaryIO) -> Tuple[int, int]: + """ + Read a record's header (size and tag). + Args: + stream: stream to read from + Returns: + data_size: size of data (not including header) + tag: Record type tag + """ + header = read(stream, 4) + record_size, tag = _RECORD_HEADER_FMT.unpack(header) + if record_size < 4: + raise KlamathError(f'Record size is too small: {record_size} @ pos 0x{stream.tell():x}') + if record_size % 2: + raise KlamathError(f'Record size is odd: {record_size} @ pos 0x{stream.tell():x}') + data_size = record_size - 4 # substract header size + return data_size, tag + + +def expect_record(stream: BinaryIO, tag: int) -> int: + data_size, actual_tag = read_record_header(stream) + if tag != actual_tag: + raise KlamathError(f'Unexpected record! Got tag {actual_tag:04x}, expected {tag:04x}') + return data_size + + +R = TypeVar('R', bound='Record') + + +class Record(metaclass=ABCMeta): + tag: ClassVar[int] = -1 + expected_size: ClassVar[Optional[int]] = None + + @classmethod + def check_size(cls, size: int): + if cls.expected_size is not None and size != cls.expected_size: + raise KlamathError(f'Expected size {cls.expected_size}, got {size}') + + @classmethod + def check_data(cls, data): + pass + + @classmethod + @abstractmethod + def read_data(cls, stream: BinaryIO, size: int): + pass + + @classmethod + @abstractmethod + def pack_data(cls, data) -> bytes: + pass + + @staticmethod + def read_header(stream: BinaryIO) -> Tuple[int, int]: + return read_record_header(stream) + + @classmethod + def write_header(cls, stream: BinaryIO, data_size: int) -> int: + return write_record_header(stream, data_size, cls.tag) + + @classmethod + def skip_past(cls, stream: BinaryIO) -> bool: + """ + Skip to the end of the next occurence of this record. + + Args: + stream: Seekable stream to read from. + + Return: + True if the record was encountered and skipped. + False if the end of the library was reached. + """ + from .records import ENDLIB + size, tag = Record.read_header(stream) + while tag != cls.tag: + stream.seek(size, io.SEEK_CUR) + if tag == ENDLIB.tag: + return False + size, tag = Record.read_header(stream) + stream.seek(size, io.SEEK_CUR) + return True + + @classmethod + def skip_and_read(cls, stream: BinaryIO): + size, tag = Record.read_header(stream) + while tag != cls.tag: + stream.seek(size, io.SEEK_CUR) + size, tag = Record.read_header(stream) + data = cls.read_data(stream, size) + return data + + @classmethod + def read(cls: Type[R], stream: BinaryIO): + size = expect_record(stream, cls.tag) + data = cls.read_data(stream, size) + return data + + @classmethod + def write(cls, stream: BinaryIO, data) -> int: + data_bytes = cls.pack_data(data) + b = cls.write_header(stream, len(data_bytes)) + b += stream.write(data_bytes) + return b + + +class NoDataRecord(Record): + expected_size: ClassVar[Optional[int]] = 0 + + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> None: + stream.read(size) + + @classmethod + def pack_data(cls, data: None) -> bytes: + if data is not None: + raise KlamathError('?? Packing {data} into NoDataRecord??') + return b'' + + +class BitArrayRecord(Record): + expected_size: ClassVar[Optional[int]] = 2 + + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> int: + return parse_bitarray(read(stream, 2)) + + @classmethod + def pack_data(cls, data: int) -> bytes: + return pack_bitarray(data) + + +class Int2Record(Record): + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray: + return parse_int2(read(stream, size)) + + @classmethod + def pack_data(cls, data: Sequence[int]) -> bytes: + return pack_int2(data) + + +class Int4Record(Record): + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray: + return parse_int4(read(stream, size)) + + @classmethod + def pack_data(cls, data: Sequence[int]) -> bytes: + return pack_int4(data) + + +class Real8Record(Record): + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> numpy.ndarray: + return parse_real8(read(stream, size)) + + @classmethod + def pack_data(cls, data: Sequence[int]) -> bytes: + return pack_real8(data) + + +class ASCIIRecord(Record): + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> bytes: + return parse_ascii(read(stream, size)) + + @classmethod + def pack_data(cls, data: bytes) -> bytes: + return pack_ascii(data) + + +class DateTimeRecord(Record): + @classmethod + def read_data(cls, stream: BinaryIO, size: int) -> List[datetime]: + return parse_datetime(read(stream, size)) + + @classmethod + def pack_data(cls, data: Sequence[datetime]) -> bytes: + return pack_datetime(data) diff --git a/klamath/records.py b/klamath/records.py new file mode 100644 index 0000000..3d3e838 --- /dev/null +++ b/klamath/records.py @@ -0,0 +1,333 @@ +""" +Record type and tag definitions +""" +from typing import Sequence + +from .record import NoDataRecord, BitArrayRecord, Int2Record, Int4Record, Real8Record +from .record import ASCIIRecord, DateTimeRecord + + +class HEADER(Int2Record): + tag = 0x0002 + expected_size = 2 + + +class BGNLIB(DateTimeRecord): + tag = 0x0102 + expected_size = 6 * 2 + + +class LIBNAME(ASCIIRecord): + tag = 0x0206 + + +class UNITS(Real8Record): + """ (user_units_per_db_unit, db_units_per_meter) """ + tag = 0x0305 + expected_size = 8 * 2 + + +class ENDLIB(NoDataRecord): + tag = 0x0400 + + +class BGNSTR(DateTimeRecord): + tag = 0x0502 + expected_size = 6 * 2 + + +class STRNAME(ASCIIRecord): + """ Legal characters are `?A-Za-z0-9_$` """ + tag = 0x0606 + + +class ENDSTR(NoDataRecord): + tag = 0x0700 + + +class BOUNDARY(NoDataRecord): + tag = 0x0800 + + +class PATH(NoDataRecord): + tag = 0x0900 + + +class SREF(NoDataRecord): + tag = 0x0a00 + + +class AREF(NoDataRecord): + tag = 0x0b00 + + +class TEXT(NoDataRecord): + tag = 0x0c00 + + +class LAYER(Int2Record): + tag = 0x0d02 + expected_size = 2 + + +class DATATYPE(Int2Record): + tag = 0x0e02 + expected_size = 2 + + +class WIDTH(Int4Record): + tag = 0x0f03 + expected_size = 4 + + +class XY(Int4Record): + tag = 0x1003 + + +class ENDEL(NoDataRecord): + tag = 0x1100 + + +class SNAME(ASCIIRecord): + tag = 0x1206 + + +class COLROW(Int2Record): + tag = 0x1302 + expected_size = 4 + + +class NODE(NoDataRecord): + tag = 0x1500 + + +class TEXTTYPE(Int2Record): + tag = 0x1602 + expected_size = 2 + + +class PRESENTATION(BitArrayRecord): + tag = 0x1701 + + +class SPACING(Int2Record): + tag = 0x1802 #Not sure about 02; Unused + + +class STRING(ASCIIRecord): + tag = 0x1906 + + +class STRANS(BitArrayRecord): + tag = 0x1a01 + + +class MAG(Real8Record): + tag = 0x1b05 + expected_size = 8 + + +class ANGLE(Real8Record): + tag = 0x1c05 + expected_size = 8 + + +class UINTEGER(Int2Record): + tag = 0x1d02 #Unused; not sure about 02 + + +class USTRING(ASCIIRecord): + tag = 0x1e06 #Unused; not sure about 06 + + +class REFLIBS(ASCIIRecord): + tag = 0x1f06 + + @classmethod + def check_size(cls, size: int): + if size != 0 and size % 44 != 0: + raise Exception(f'Expected size to be multiple of 44, got {size}') + + +class FONTS(ASCIIRecord): + tag = 0x2006 + + @classmethod + def check_size(cls, size: int): + if size != 0 and size % 44 != 0: + raise Exception(f'Expected size to be multiple of 44, got {size}') + + +class PATHTYPE(Int2Record): + tag = 0x2102 + expected_size = 2 + + +class GENERATIONS(Int2Record): + tag = 0x2202 + expected_size = 2 + + @classmethod + def check_data(cls, data: Sequence[int]): + if len(data) != 1: + raise Exception(f'Expected exactly one integer, got {data}') + + +class ATTRTABLE(ASCIIRecord): + tag = 0x2306 + + @classmethod + def check_size(cls, size: int): + if size > 44: + raise Exception(f'Expected size <= 44, got {size}') + + +class STYPTABLE(ASCIIRecord): + tag = 0x2406 #UNUSED, not sure about 06 + + +class STRTYPE(Int2Record): + tag = 0x2502 #UNUSED + + +class ELFLAGS(BitArrayRecord): + tag = 0x2601 + + +class ELKEY(Int2Record): + tag = 0x2703 # UNUSED + + +class LINKTYPE(Int2Record): + tag = 0x2803 # UNUSED + + +class LINKKEYS(Int2Record): + tag = 0x2903 # UNUSED + + +class NODETYPE(Int2Record): + tag = 0x2a02 + expected_size = 2 + + +class PROPATTR(Int2Record): + tag = 0x2b02 + expected_size = 2 + + +class PROPVALUE(ASCIIRecord): + tag = 0x2c06 + expected_size = 2 + + +class BOX(NoDataRecord): + tag = 0x2d00 + + +class BOXTYPE(Int2Record): + tag = 0x2e02 + expected_size = 2 + + +class PLEX(Int4Record): + tag = 0x2f03 + expected_size = 4 + + +class BGNEXTN(Int4Record): + tag = 0x3003 + + +class ENDEXTN(Int4Record): + tag = 0x3103 + + +class TAPENUM(Int2Record): + tag = 0x3202 + expected_size = 2 + + +class TAPECODE(Int2Record): + tag = 0x3302 + expected_size = 12 + + +class STRCLASS(Int2Record): + tag = 0x3401 # UNUSED + + +class RESERVED(Int2Record): + tag = 0x3503 # UNUSED + + +class FORMAT(Int2Record): + tag = 0x3602 + expected_size = 2 + + @classmethod + def check_data(cls, data: Sequence[int]): + if len(data) != 1: + raise Exception(f'Expected exactly one integer, got {data}') + + +class MASK(ASCIIRecord): + """ List of layers and dtypes """ + tag = 0x3706 + + +class ENDMASKS(NoDataRecord): + """ End of MASKS records """ + tag = 0x3800 + + +class LIBDIRSIZE(Int2Record): + tag = 0x3902 + + +class SRFNAME(ASCIIRecord): + tag = 0x3a06 + + +class LIBSECUR(Int2Record): + tag = 0x3b02 + + +class BORDER(NoDataRecord): + tag = 0x3c00 + + +class SOFTFENCE(NoDataRecord): + tag = 0x3d00 + + +class HARDFENCE(NoDataRecord): + tag = 0x3f00 + + +class SOFTWIRE(NoDataRecord): + tag = 0x3f00 + + +class HARDWIRE(NoDataRecord): + tag = 0x4000 + + +class PATHPORT(NoDataRecord): + tag = 0x4100 + + +class NODEPORT(NoDataRecord): + tag = 0x4200 + + +class USERCONSTRAINT(NoDataRecord): + tag = 0x4300 + + +class SPACERERROR(NoDataRecord): + tag = 0x4400 + + +class CONTACT(NoDataRecord): + tag = 0x4500 diff --git a/klamath/test_basic.py b/klamath/test_basic.py new file mode 100644 index 0000000..fd36e90 --- /dev/null +++ b/klamath/test_basic.py @@ -0,0 +1,119 @@ +import struct + +import pytest # type: ignore +import numpy # type: ignore +from numpy.testing import assert_array_equal # type: ignore + +from .basic import parse_bitarray, parse_int2, parse_int4, parse_real8, parse_ascii +from .basic import pack_bitarray, pack_int2, pack_int4, pack_real8, pack_ascii +from .basic import decode_real8, encode_real8 + +from .basic import KlamathError + + +def test_parse_bitarray(): + assert(parse_bitarray(b'59') == 13625) + assert(parse_bitarray(b'\0\0') == 0) + assert(parse_bitarray(b'\xff\xff') == 65535) + + # 4 bytes (too long) + with pytest.raises(KlamathError): + parse_bitarray(b'4321') + + # empty data + with pytest.raises(KlamathError): + parse_bitarray(b'') + + +def test_parse_int2(): + assert_array_equal(parse_int2(b'59\xff\xff\0\0'), (13625, -1, 0)) + + # odd length + with pytest.raises(KlamathError): + parse_int2(b'54321') + + # empty data + with pytest.raises(KlamathError): + parse_int2(b'') + + +def test_parse_int4(): + assert_array_equal(parse_int4(b'4321'), (875770417,)) + + # length % 4 != 0 + with pytest.raises(KlamathError): + parse_int4(b'654321') + + # empty data + with pytest.raises(KlamathError): + parse_int4(b'') + + +def test_decode_real8(): + # zeroes + assert(decode_real8(numpy.array([0x0])) == 0) + assert(decode_real8(numpy.array([1<<63])) == 0) # negative + assert(decode_real8(numpy.array([0xff << 56])) == 0) # denormalized + + assert(decode_real8(numpy.array([0x4110 << 48])) == 1.0) + assert(decode_real8(numpy.array([0xC120 << 48])) == -2.0) + + +def test_parse_real8(): + packed = struct.pack('>3Q', 0x0, 0x4110_0000_0000_0000, 0xC120_0000_0000_0000) + assert_array_equal(parse_real8(packed), (0.0, 1.0, -2.0)) + + # length % 8 != 0 + with pytest.raises(KlamathError): + parse_real8(b'0987654321') + + # empty data + with pytest.raises(KlamathError): + parse_real8(b'') + + +def test_parse_ascii(): + # empty data + with pytest.raises(KlamathError): + parse_ascii(b'') + + assert(parse_ascii(b'12345') == b'12345') + assert(parse_ascii(b'12345\0') == b'12345') # strips trailing null byte + + +def test_pack_bitarray(): + packed = pack_bitarray(321) + assert(len(packed) == 2) + assert(packed == struct.pack('>H', 321)) + + +def test_pack_int2(): + packed = pack_int2((3, 2, 1)) + assert(len(packed) == 3*2) + assert(packed == struct.pack('>3h', 3, 2, 1)) + assert(pack_int2([-3, 2, -1]) == struct.pack('>3h', -3, 2, -1)) + + +def test_pack_int4(): + packed = pack_int4((3, 2, 1)) + assert(len(packed) == 3*4) + assert(packed == struct.pack('>3l', 3, 2, 1)) + assert(pack_int4([-3, 2, -1]) == struct.pack('>3l', -3, 2, -1)) + + +def test_encode_real8(): + assert(encode_real8(numpy.array([0.0])) == 0) + arr = numpy.array((1.0, -2.0, 1e-9, 1e-3, 1e-12)) + assert_array_equal(decode_real8(encode_real8(arr)), arr) + + +def test_pack_real8(): + reals = (0, 1, -1, 0.5, 1e-9, 1e-3, 1e-12) + packed = pack_real8(reals) + assert(len(packed) == len(reals) * 8) + assert_array_equal(parse_real8(packed), reals) + + +def test_pack_ascii(): + assert(pack_ascii(b'4321') == b'4321') + assert(pack_ascii(b'321') == b'321\0') diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a79d0f8 --- /dev/null +++ b/setup.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +from setuptools import setup, find_packages + + +with open('README.md', 'r') as f: + long_description = f.read() + +with open('klamath/VERSION', 'r') as f: + version = f.read().strip() + +setup(name='klamath', + version=version, + description='GDSII format reader/writer', + long_description=long_description, + long_description_content_type='text/markdown', + author='Jan Petykiewicz', + author_email='anewusername@gmail.com', + url='https://mpxd.net/code/jan/klamath', + packages=find_packages(), + package_data={ + 'klamath': ['VERSION', + 'py.typed', + ] + }, + install_requires=[ + 'numpy', + ], + classifiers=[ + 'Programming Language :: Python :: 3', + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Manufacturing', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: GNU General Public License v3', + 'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)', + ], + keywords=[ + 'layout', + 'design', + 'CAD', + 'EDA', + 'electronics', + 'photonics', + 'IC', + 'mask', + 'pattern', + 'drawing', + 'lithography', + 'litho', + 'geometry', + 'geometric', + 'polygon', + 'gds', + 'gdsii', + 'gds2', + 'stream', + 'vector', + 'freeform', + 'manhattan', + 'angle', + 'Calma', + ], + )