diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..10490bc --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,19 @@ +Convert FiberWorks dtx handweaving files to WIF 1.1 + +This is a command-line script. To run it: + +`$ ./dtx_to_wif path1 path2 ... --overwrite` + +This will recursively scan each provided path (which may be a file or directory) for files whose names end in ".dtx". For each such file it finds, it will write a new WIF file in the same directory, with the standard ".wif" extension, provided such a file does not already exist. You can specify option `--overwrite` to (silently) overwrite existing WIF files. + +Specify `--help` (or `-h`) to print help. + +Known differences from the WIF files that FiberWorks writes: + +- If no color information is given, this code writes a 2-element color table, whereas FiberWorks writes a much longer table. +- The warp/weft color sections specify the color of every end and every pick. By comparison, FiberWorks omits entries when the color is the default. +- The default color of weft (and possibly warp) yarns will likely not match FiberWorks's WIF files. I have not figured out the algorithm FiberWorks uses to pick a default weft color. + +The script is written in Python 3 and uses only standard libraries. + +This software is licensed under the MIT license; see license.text for details. diff --git a/dtx_to_wif b/dtx_to_wif new file mode 100755 index 0000000..7030ba1 --- /dev/null +++ b/dtx_to_wif @@ -0,0 +1,394 @@ +#!/usr/bin/env python +import argparse +import pathlib +import re + +AlternateFWSectionNames = { + "color palette": "color palet", +} + + +class SectionData: + """Data for a section of a dtx file. + + The fields are: + • metadata: a dict of name: value (if any, else None) + • data: a list of non-metadata values. + For many sections the data is simply the strings found in the file, + one element per line of data, However, for sections that are a list of integers + spread over multiple lines (threading, warp colors, warp spacing, + weft colors, weft spacing) the data is a list of the integer values + (ignoring line breaks). + Similarly the treadling section is either a list of one integer per treadle, + or, for compound treadling, a list of lists of treadles, one per pick. + """ + + def __init__(self): + self.metadata = dict() + self.data = list() + + def add_line(self, line): + if line.startswith("%%"): + # Metadata is of the form {key}[ {value}] (i.e. the value is optional) + data = line[2:].split(None, maxsplit=1) + if len(data) < 2: + data.append(None) + self.metadata[data[0]] = data[1] + else: + self.data.append(line) + + +def parse_dtx_file(filepath): + """Parse a dtx weaving file. + + Return a data dict whose keys are sections + and values are SectionData instances. + + Leading and trailing whitespace are stripped + and blank lines are ignored. + + The file is not checked for syntactic correctness. + """ + with open(filepath) as f: + data = dict() + section_name = "" + for line in f: + line = line.strip() + if not line: + continue + + if line.startswith("@@"): + section_name = line[2:].strip().lower() + section_name = AlternateFWSectionNames.get(section_name, section_name) + data[section_name] = SectionData() + else: + data[section_name].add_line(line) + + for section_name in ( + "threading", + "warp colors", + "warp spacing", + "weft colors", + "weft spacing", + ): + section_info = data.get(section_name) + if section_info is None: + continue + process_int_list(section_info) + + treadling_info = data.get("treadling") + if treadling_info is not None: + process_treadling(treadling_info) + + return data + + +def process_int_list(section_info): + """Process a section of space-separated ints distributed over multiple lines. + + Convert section_info.data in place. + """ + + section_data_str = " ".join(section_info.data) + section_info.data = [int(item) for item in section_data_str.split()] + + +def process_treadling(treadling_info): + """Process the treadling section. + + Convert treadling_info.data in place. + + Input format: + + * Treadle numbers for a given pick are separated by ", " + * Treadle sets for each pick are separated by pure spaces + + The processed format is a list of treadles per pick, where the treadles + for a given pick are a list of one or more treadle numbers (ints). + + For example, if the compound treadling is as follows: + + pick 1: 1 3 4 + pick 2: 2 + pick 3: 1 4 + + Then the input data is "1, 3, 4 2 1, 4" + and the processed data is [[1, 3, 4], 2, [1, 4]] + """ + treadling_data_str = " ".join(treadling_info.data) + # Delete spaces after commas, so we can split the resulting string + # on spaces to obtain comma-separated sets of treadles + compressed_treadling_data_str = re.sub(", +", ",", treadling_data_str) + treadle_sets = compressed_treadling_data_str.split() + treadling_info.data = [ + [int(treadle) for treadle in treadle_set.split(",")] + for treadle_set in treadle_sets + ] + + +def get_data_item(data, section_name, index, default=None): + """Get one element of data. + + Parameters + ---------- + data: parsed dtx data; a dict of SectionInfo from parse_dtx_file + section_name: the name of the dtx section (lowercase) + index: the index of the data element + default: the value to return if that data element does not exist + (the section or index does not exist). + """ + section_info = data.get(section_name) + if section_info is None: + return default + if len(section_info.data) <= index: + return default + return section_info.data[index] + + +def as_wif_bool(value): + """Return "true" if bool(value), else "false" + + Used to create logical values that match the format FiberWorks uses + when it writes WIF files. + """ + return "true" if value else "false" + + +def make_wif_spacing(section_info): + """Convert a weft spacing or warp spacing from dtx to wif. + + Return a list of (1-based-pick, spacing in inches) + for picks that don't use the default dtx thickness of 4 + """ + if section_info is None: + return [] + return [ + (pick + 1, spacing * 0.053) + for pick, spacing in enumerate(section_info.data) + if spacing != 4 + ] + + +def write_wif(filepath, data): + """Write a WIF file from parsed dtx data. + + Parameters + ---------- + filepath: a pathlib.Path pointing to a specific dtx file. + data: parsed dtx data; a dict of SectionInfo from parse_dtx_file + + """ + with open(filepath, "w") as f: + fw_version = get_data_item(data, "imprint", 0, "? ?").split()[1] + if "." in fw_version: + # Drop everything after major.minor, if present + fw_version = ".".join(fw_version.split(".")[0:2]) + drawdown_name = filepath.name + drawdown_date = get_data_item(data, "imprint", 1, "?") + color_palette = data.get("color palet") + weft_colors = data.get("weft colors") + warp_colors = data.get("warp colors") + weft_spacing_list = make_wif_spacing(data.get("weft spacing")) + warp_spacing_list = make_wif_spacing(data.get("warp spacing")) + liftplan = data.get("liftplan") + tieup = data.get("tieup") + treadling = data.get("treadling") + threading = data["threading"] # required + num_shafts = max(threading.data) + num_ends = len(threading.data) + if warp_colors is not None: + default_warp_color = warp_colors.data[0] + 1 + else: + default_warp_color = 1 + if weft_colors is not None: + default_weft_color = weft_colors.data[0] + 1 + else: + default_weft_color = 2 + if liftplan is not None: + num_treadles = len(liftplan.data[0]) + num_picks = len(liftplan.data) + else: + if tieup is None or treadling is None: + raise RuntimeError( + f"Cannot parse {filepath!r}: no Liftplan, but Tieup and/or Treadling are missing" + ) + num_treadles = len(tieup.data[0]) + num_picks = len(treadling.data) + + f.write( + f"""[WIF] +Version=1.1 +Date=April 20, 1997 +Developers=wif@mhsoft.com +Source Program=Fiberworks PCW +Source Version={fw_version} + +[CONTENTS] +COLOR PALETTE={as_wif_bool(color_palette)} +TEXT=true +WEAVING=true +WARP=true +WEFT=true +COLOR TABLE=true +THREADING=true +TIEUP={as_wif_bool(tieup)} +TREADLING={as_wif_bool(treadling)} +LIFTPLAN={as_wif_bool(liftplan)} +WARP COLORS={as_wif_bool(warp_colors)} +WEFT COLORS={as_wif_bool(weft_colors)} +WARP SPACING={as_wif_bool(warp_spacing_list)} +WEFT SPACING={as_wif_bool(weft_spacing_list)} + +[TEXT] +Title={drawdown_name} +; Creation {drawdown_date} +""" + ) + + if weft_colors is not None: + f.write("\n[WEFT COLORS]\n") + for pick, color_index in enumerate(weft_colors.data): + f.write(f"{pick + 1}={color_index + 1}\n") + + if warp_colors is not None: + f.write("\n[WARP COLORS]\n") + for pick, color_index in enumerate(warp_colors.data): + f.write(f"{pick + 1}={color_index + 1}\n") + + if weft_spacing_list: + f.write("\n[WEFT SPACING]\n") + for item in weft_spacing_list: + f.write(f"{item[0]}={item[1]:0.3f}\n") + + if warp_spacing_list: + f.write("\n[WARP SPACING]\n") + for item in warp_spacing_list: + f.write(f"{item[0]}={item[1]:0.3f}\n") + + threading_data = threading.data + f.write("\n[THREADING]\n") + for end, shaft in enumerate(threading_data): + f.write(f"{end+1}={shaft}\n") + + if tieup is not None: + f.write("\n[TIEUP]\n") + # The data is transposed! + # In dxf rows are shafts, with shaft 1 as the last data list element, + # and columns are treadles, with treadle 1 the first char of the bool str + # In wif the rows are treadles (1-based) + # and values are comma-separated shafts (1-based). + for treadle in range(len(tieup.data[0])): + reversed_data = reversed(tieup.data) + shafts = [ + str(i + 1) + for i, boolstr in enumerate(reversed_data) + if boolstr[treadle] == "1" + ] + shafts_str = ",".join(shafts) + f.write(f"{treadle+1}={shafts_str}\n") + + if treadling is not None: + f.write("\n[TREADLING]\n") + for pick, treadle_list in enumerate(treadling.data): + treadle_str = ",".join(str(treadle) for treadle in treadle_list) + f.write(f"{pick+1}={treadle_str}\n") + + if liftplan is not None: + f.write("\n[LIFTPLAN]\n") + for pick, boolstr in enumerate(liftplan.data): + shafts = [str(i + 1) for i, value in enumerate(boolstr) if value == "1"] + shafts_str = ",".join(shafts) + f.write(f"{pick+1}={shafts_str}\n") + + f.write( + f""" +[WEAVING] +Rising Shed=true +Treadles={num_treadles} +Shafts={num_shafts} + +[WARP] +Units=centimeters +Color={default_warp_color} +Threads={num_ends} +Spacing=0.212 +Thickness=0.212 + +[WEFT] +Units=centimeters +Color={default_weft_color} +Threads={num_picks} +Spacing=0.212 +Thickness=0.212 +""" + ) + + if color_palette: + f.write("\n[COLOR TABLE]\n") + for i, entry in enumerate(color_palette.data): + dxf_intvalues = [int(value) for value in entry.split(",")] + if len(dxf_intvalues) != 3: + raise RuntimeError( + f"Error in {filepath[:-4]}.dtx: cannot parse item {i+1} of Color Palet: {entry}" + ) + wif_strvalues = [ + str((intvalue * 999) // 255) for intvalue in dxf_intvalues + ] + wif_str = ",".join(wif_strvalues) + f.write(f"{i+1}={wif_str}\n") + + f.write( + f""" +[COLOR PALETTE] +Range=0,999 +Entries={len(color_palette.data)} +""" + ) + else: + f.write( + f""" +[COLOR TABLE] +1=999,999,999 +2=0,0,999 + +[COLOR PALETTE] +Range=0,999 +Entries=2 +""" + ) + + +parser = argparse.ArgumentParser(description="Convert a dtx file to wif") +parser.add_argument( + "inpath", nargs="+", help="dtx files or directories of files to parse" +) +parser.add_argument( + "--overwrite", action="store_true", help="overwrite existing files?" +) +parser.add_argument("-v", "--verbose", action="store_true", help="print parsed data") +args = parser.parse_args() +for inpathstr in args.inpath: + inpath = pathlib.Path(inpathstr) + for infile in inpath.rglob("*.dtx"): + outfile = infile.with_suffix(".wif") + if not args.overwrite and outfile.exists(): + print(f"Skipping {infile} because {outfile} already exists") + continue + + print(f"Writing {outfile}") + try: + data = parse_dtx_file(infile) + write_wif(outfile, data) + except Exception as e: + print(f"Failed on {infile}: {e}") + if args.verbose: + for section_name, section_info in data.items(): + print(f"Section: {section_name!r}") + if section_info.metadata: + print(" Metadata:") + for key, value in section_info.metadata.items(): + print(f" {key}={value!r}") + if section_info.data: + print(" Data:") + for line in section_info.data: + print(f" {line!r}") diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..ea47a6a --- /dev/null +++ b/license.txt @@ -0,0 +1,7 @@ +Copyright 2024 Russell Owen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.