initial commit

This commit is contained in:
Jan Petykiewicz 2020-09-26 17:55:17 -07:00
commit 034d5fbcb7
19 changed files with 1818 additions and 0 deletions

200
README.md Normal file
View File

@ -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()
```

1
klamath/VERSION Normal file
View File

@ -0,0 +1 @@
0.1

42
klamath/__init__.py Normal file
View File

@ -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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

174
klamath/basic.py Normal file
View File

@ -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

489
klamath/elements.py Normal file
View File

@ -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

187
klamath/library.py Normal file
View File

@ -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

0
klamath/py.typed Normal file
View File

208
klamath/record.py Normal file
View File

@ -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)

333
klamath/records.py Normal file
View File

@ -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

119
klamath/test_basic.py Normal file
View File

@ -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')

65
setup.py Normal file
View File

@ -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',
],
)