From 034d5fbcb726185742d6f5f0876bfbd6b0bcf8fc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 26 Sep 2020 17:55:17 -0700 Subject: [PATCH] initial commit --- README.md | 200 +++++++ klamath/VERSION | 1 + klamath/__init__.py | 42 ++ klamath/__pycache__/__init__.cpython-38.pyc | Bin 0 -> 467 bytes klamath/__pycache__/basic.cpython-38.pyc | Bin 0 -> 5363 bytes klamath/__pycache__/elements.cpython-38.pyc | Bin 0 -> 11673 bytes klamath/__pycache__/library.cpython-38.pyc | Bin 0 -> 5419 bytes klamath/__pycache__/record.cpython-38.pyc | Bin 0 -> 7966 bytes klamath/__pycache__/records.cpython-38.pyc | Bin 0 -> 11031 bytes .../test_basic.cpython-38-PYTEST.pyc | Bin 0 -> 13161 bytes .../test_record.cpython-38-PYTEST.pyc | Bin 0 -> 136 bytes klamath/basic.py | 174 +++++++ klamath/elements.py | 489 ++++++++++++++++++ klamath/library.py | 187 +++++++ klamath/py.typed | 0 klamath/record.py | 208 ++++++++ klamath/records.py | 333 ++++++++++++ klamath/test_basic.py | 119 +++++ setup.py | 65 +++ 19 files changed, 1818 insertions(+) create mode 100644 README.md create mode 100644 klamath/VERSION create mode 100644 klamath/__init__.py create mode 100644 klamath/__pycache__/__init__.cpython-38.pyc create mode 100644 klamath/__pycache__/basic.cpython-38.pyc create mode 100644 klamath/__pycache__/elements.cpython-38.pyc create mode 100644 klamath/__pycache__/library.cpython-38.pyc create mode 100644 klamath/__pycache__/record.cpython-38.pyc create mode 100644 klamath/__pycache__/records.cpython-38.pyc create mode 100644 klamath/__pycache__/test_basic.cpython-38-PYTEST.pyc create mode 100644 klamath/__pycache__/test_record.cpython-38-PYTEST.pyc create mode 100644 klamath/basic.py create mode 100644 klamath/elements.py create mode 100644 klamath/library.py create mode 100644 klamath/py.typed create mode 100644 klamath/record.py create mode 100644 klamath/records.py create mode 100644 klamath/test_basic.py create mode 100644 setup.py 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 0000000000000000000000000000000000000000..b9e48f76a964f82a7395c11235d174cdca7c3911 GIT binary patch literal 467 zcmYk2IZi_{5J1O&&qg97BqSQzG^C9Y0wilfxEH;+FyEm{8 z7jOx;)N=&=){6SWa7Ax~KC|JStn`>9GB_@bB8z{lB26&G4D;G~oa#Fx&1~L1mHF;D z*0TqV&w4z!@%8cL=;Ul@nd$dzT*`VvXDt|?dYHf=bch^cf^7;RAL~hBgs?$TVs3+T zx@8j~?sbZ^8R<+TkZ3*#&rqV?spAta%Pi*tWb|B9=eaHdW6Jlv)|zd-ZuLNy<~cA literal 0 HcmV?d00001 diff --git a/klamath/__pycache__/basic.cpython-38.pyc b/klamath/__pycache__/basic.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e8de973ae59f13411be55b060d8d14328df1d39 GIT binary patch literal 5363 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 literal 0 HcmV?d00001 diff --git a/klamath/__pycache__/library.cpython-38.pyc b/klamath/__pycache__/library.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71a72fb20600438153181043b94fc18f275cdb28 GIT binary patch literal 5419 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 UwC7Red}HZ`p2vmnzp9J>1>S8#od5s; literal 0 HcmV?d00001 diff --git a/klamath/__pycache__/record.cpython-38.pyc b/klamath/__pycache__/record.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30e6290ed554d124965fe1565e41e9b4254ec359 GIT binary patch literal 7966 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0a4d0de8b228269b9967df9b20cfe25bc52e2481 GIT binary patch literal 136 zcmWIL<>m5vl^)Lk1dl-kGGGL99Dul(1xTbY1T$zd`mJOr0tq9CUxxY_`MIh3S&4c2 z1x5K;smUe9`q?>&xrrqiaJnS5xFo(PH95a1MX#Xp7Kcr4eoARhsvXFv&p^xo0MEZ5 AAOHXW literal 0 HcmV?d00001 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', + ], + )