Install the app
How to install the app on iOS

Follow along with the video below to see how to install our site as a web app on your home screen.

Note: This feature may not be available in some browsers.

[Tools] PersonA ~Opera Za no Kaijin~ Translation Tools

Shisaye

Moderator
Moderator
Elite
Aug 17, 2016
579
2,940
proof.png

Overview:
Python scripts to unpack/pack K5 archives, decrypt/encrypt sce/sdb files, and collect translatable strings from/to sce/sdb files.
Specifically for PersonA ~Opera Za no Kaijin~ (PersonA Phantom of the Opera).​

Developer: Shisaye
Version: 1
System Requirements: Python
 

Attachments

This is more about how to use the tool in the OP than how to seriously translate the game, so I figured this would be the better place to show the overall workflow.

the local environment must already have python, translation tools , and a sugoi server, PersonA Translation Tools V1

- download the game from Yui87's thread https://www.anime-sharing.com/threads/persona-opera-za-no-kaijin-persona-~オペラ座の怪人~.1187522/
- Run it and make sure it works. Locale emulator did not work for me with this title. The locale had to be swapped to Japanese, Japan.

- install python https://www.python.org/downloads/

- install translation tools, romaji.py/sugoi_mtl.py/openai_mtl.py, https://codeberg.org/entai2965 -> translation tools, read the readme to see the setup for romaji.py and sugoi_mtl.py
- If you want a sugoi server, my repackage is pretty fast https://forums.fuwanovel.moe/topic/27430-tool-sugoi-offline-translator-repackage

- And finally download and unpack PersonA Translation Tools V1 from this thead to this directory structure

Code:
PersonA Opera Za no Kaijin
PersonA Opera Za no Kaijin\bin\
PersonA Opera Za no Kaijin\bin\PersonA Opera Za no Kaijin\PersonA.exe
PersonA Opera Za no Kaijin\tools\
PersonA Opera Za no Kaijin\tools\PersonA Translation Tools V1
PersonA Opera Za no Kaijin\tools\translation tools

these commands assume the project structure above, change them if your environment is different

Code:
#change into the tools directory
cd PersonA Opera Za no Kaijin\tools

#change to Shisaye's project directory
cd PersonA Translation Tools V1

#dump the .json by using `python translate.py setup`
python translate.py setup "..\..\bin\PersonA Opera Za no Kaijin" work

#go back up to the tools directory
cd ..

#extract translatable strings from the .json to .csv
python personA_convert_json_to_csv.py extract "PersonA Translation Tools V1\work\dialogue.json"

#add romaji to .csv
python translation_tools\romaji.py "PersonA Translation Tools V1\work" 

#add sugoi mtl to .csv, remember to start the server first
python translation_tools\sugoi_mtl.py "PersonA Translation Tools V1\work" -a http://127.0.0.1:14366 -dapi -aw 0 -b 5000 

#edit the translation here, and/or add an AI mtl here

#insert sugoi mtl, or the edited/AI translated lines from .csv -> .json
python personA_convert_json_to_csv.py insert "PersonA Translation Tools V1\work\dialogue.json"

#after making a backup, save "PersonA Translation Tools V1\work\dialogue.json.translated.json" to "PersonA Translation Tools V1\work\dialogue.json"

#go back into the PersonA Translation Tools directory
cd PersonA Translation Tools V1

#rebuild it, the game should be in output, translated
python translate.py build "..\..\bin\PersonA Opera Za no Kaijin" work output

Once it works, it's time to re-do it in better quality.

It is also possible to skip the MTL if you want to do everything 100% by hand or go the other way and add an AI MTL to speed up editing by using translation tools\openai_mtl\openai_mtl.py

Codeberg is having issues atm, so here is the .json <-> .csv script. It is mostly copied from my previous tools. Once codeberg comes back, it will be at codeberg.com/entai2965/translation projects, A link is in my signature.

Code:
__version__='0.1.0'

debug=False
spreadsheet_encoding='utf-8'

known_spreadsheet_extensions=['.csv','.tsv','.ods','.xlsx','.xls',]

version_array=['-v','--version']
help_array=['-h','--help','/?','?']

import csv
try:
    import pyexcel
except:
    #print('pyexcel is not available, to read and write formats besides .csv, install with \'python -m pip install pyexcel pyexcel-xls pyexcel-xlsx pyexcel-ods pyexcel-ods3 pyexcel-odsr openpyxl\'')
    pyexcel=None
import sys
import os
import tempfile
import datetime
import argparse
import json

home_path=os.path.expanduser('~')
def make_path_friendly(path):
    if path.startswith(home_path):
        path=path.replace(home_path,'~',1)
    return path

def get_parent_folder(path):
    return os.path.abspath(path+'/'+os.pardir)

csv_dialects=csv.list_dialects()#['unix','excel','excel-tab']
script_name=os.path.basename(sys.argv[0])
script_path=get_parent_folder(__file__)

def get_temp_folder(filename=None):
    if not filename: return tempfile.gettempdir()+'/'+os.path.splitext(script_name)[0]
    elif filename == '': return None
    return tempfile.gettempdir()+'/'+os.path.splitext(script_name)[0]+'/'+filename

invalid_symbols_for_filenames=['\\','/',':','?','"','<','>','|','~','!','{','}','(',')','-','♥','☆',',','×','♪']
def make_filename_write_friendly(filename,ascii_only=True):
    filename=filename.strip()
    if ascii_only: filename=filename.encode('ascii',errors='replace').decode().replace('?','')
    for symbol in invalid_symbols_for_filenames:
        if symbol in filename:
            filename=filename.replace(symbol,'_')
    return filename

def read_file(filename,encoding='utf-8',as_json=False):
    if not filename: return None
    if not os.path.isfile(filename):
        return None
    print('reading',make_path_friendly(filename))
    if encoding == 'binary':
        with open(filename,'rb') as file:
            return bytearray(file.read())
    else:
        with open(filename,'r',encoding=encoding) as file:
            if as_json: return json.loads(file.read())
            else: return file.read()

def write_file(filename,data,encoding='utf-8',as_json=False,force=False):
    if not filename: return None
    if not data: return None
    if len(data) == 0: return None
    if os.path.isfile(filename) and (not force):
        print(filename,'already exists')
        return False
    if encoding == 'binary':
        if isinstance(data,str):
            data=data.encode('utf-8')
        with open(filename,'wb') as file:
            file.write(data)
    else:
        with open(filename, 'w',encoding=encoding) as file:
            if as_json: json.dump(data,file,ensure_ascii=False,indent=4)
            else: file.write(data)
    if os.path.isfile(filename):
        print('wrote',make_path_friendly(filename))
        return True
    return False


#accepts a spreadsheet path and returns either a list or character_names.csv as a Python dictionary
def read_spreadsheet(spreadsheet_path=None,csv_dialect=None,character_dictionary=False):
    if character_dictionary == False:
        spreadsheet=[]
    else:
        spreadsheet={}
    if spreadsheet_path == None:
        return spreadsheet
    print('reading',os.path.basename(spreadsheet_path))
    #open spreadsheet.csv
    #check if input is a .csv
    extension=os.path.splitext(spreadsheet_path)[1].lower()
    if (extension == '.csv') or (extension == '.tsv'):
        #https://docs.python.org/3/library/csv.html
        with open(spreadsheet_path,'rt',encoding=spreadsheet_encoding,newline='',errors='strict') as file:
            if csv_dialect == None:
                if extension == '.csv': csv_file=csv.reader(file)
                elif extension == '.tsv': csv_file=csv.reader(file,delimiter='\t')
            else:
                if extension == '.csv': csv_file=csv.reader(file,dialect=csv_dialect)
                elif extension == '.tsv': csv_file=csv.reader(file,dialect=csv_dialect,delimiter='\t')
            #"A csvfile must be an iterable of strings, each in the reader's defined csv format. A csvfile is most commonly a file-like object or list."
            for index,csv_list in enumerate(csv_file):
                if debug == True:
                    print(str(csv_list))
                if character_dictionary == False:
                    spreadsheet.append([i.strip() for i in csv_list])
                else:
                    if index == 0:
                        continue
                    if len(csv_list) > 1:
                        if csv_list[1] != None:
                            if csv_list[1].strip() != '':
                                spreadsheet[csv_list[0].strip()]=csv_list[1].strip()
    else:
        if character_dictionary == False:
            #this returns a [[],[],[]] object where each inner list is a row
            spreadsheet=pyexcel.get_array(file_name=spreadsheet_path, start_row=0)
            for index,row in enumerate(spreadsheet):
                for i,cell in enumerate(spreadsheet[index]):
                    if cell != None:
                        spreadsheet[index][i]=cell.strip()
            if debug == True:
                print(spreadsheet)
        else:
            #this returns dictionaries, a lot of them with duplicate data
            records=pyexcel.get_records(file_name=spreadsheet_path)
            if debug == True:
                print(records)

            headers=[]
            #this relies on the python 3.7+ (cpython 3.6+) functionality of dictionaries being ordered to preserve the order of the headers
            for key in records[0].keys():
                headers.append(key)

            for row in records:
                if row[headers[1]] != None:
                    if row[headers[1]].strip() != '':
                        spreadsheet[row[headers[0]].strip()]=row[headers[1]].strip()

    return spreadsheet

def write_spreadsheet(path,data,encoding=spreadsheet_encoding,csv_dialect=None,errorhandling='namereplace'):
    if os.path.exists(path):
        return None
    if os.path.isdir(path):
        print('cannot write spreadsheet to folder path',path)
        return None
    assert isinstance(path,str)
    assert isinstance(data,list)

    extension=path[-4:].lower()
    if (extension == '.csv') or (extension == '.tsv'):
        with open(path,'wt',encoding=encoding,newline='',errors='namereplace') as file:
            if csv_dialect == None:
                if extension == '.csv': csv_file=csv.writer(file)
                elif extension == '.tsv': csv_file=csv.writer(file,delimiter='\t')
            else:
                if extension == '.csv': csv_file=csv.writer(file,dialect=csv_dialect)
                elif extension == '.tsv': csv_file=csv.writer(file,dialect=csv_dialect,delimiter='\t')
            for row in data:
                csv_file.writerow(row)
    else:
        pyexcel.save_as(array=data,dest_file_name=path)

    if os.path.isfile(path): print('wrote',path)


def is_ascii(a_string):
    is_ascii=False
    try:
        a_string.encode('ascii')
        is_ascii=True
    except:
        pass
    return is_ascii

blacklist=[
'???',
'「……」',
'MS 明朝',
'オワタ\(^o^)/',
'[',
']',
'MS ゴシック',
'IF_MW01セピア',
'IF_MWB01nセピア',
'test_白',
'test_緑',
'test_赤',
'test_青',
'test_黄',
'test_青デカ',
'「!」',
'!!',
'「……?」',
]

def extract(input_json_filename):

    input_json=read_file(input_json_filename,as_json=True)

    spreadsheet=[]

    if 'strings' not in input_json:
        print('unrecognized json format')
        return []

    strings=input_json['strings']
    #metadata_0=[] #"entry_idx"  or  "index"
    #metadata_1=[] #"string_idx" or  "offset"
    #metadata_2=[] #"event_id"   or  "max_bytes"

    #headers=['untranslated_texts','metadata']
    headers_dialogue=['untranslated_texts','entry_idx','string_idx','event_id']
    headers_gsdb_strings=['untranslated_texts','index','offset','max_bytes']

    if strings[0].get('entry_idx') is not None:
        headers=headers_dialogue
        dialogue_mode=True
    else:
        headers=headers_gsdb_strings
        dialogue_mode=False
    spreadsheet.append(headers)

    for text_dictionary in strings:
        """
        "entry_idx": 188,
        "string_idx": 3,
        "event_idx": 1058,
        "original": "MU_04",
        "translation": "MU_04"
        or
        "index": 0,
        "offset": 0,
        "max_bytes": 4,
        "original": "升䉄",
        "translation": "升䉄"
        """

        #entry_index=text_dictionary.get('entry_idx') #dialgue.json
                    #: text_dictionary.get('index') #gsdb_strings.json
        if dialogue_mode:
            entry_index=text_dictionary.get('entry_idx')
            string_index=text_dictionary.get('string_idx')
            event_index=text_dictionary.get('event_idx')
            text=text_dictionary.get('original').strip()
            if debug:
                assert entry_index != None
                assert string_index != None
                assert event_index != None
            row=[text,entry_index,string_index,event_index]
            if debug: assert row[1] != 'None'

        else:
            index=text_dictionary.get('index')
            offset=text_dictionary.get('offset')
            max_bytes=text_dictionary.get('max_bytes')
            text=text_dictionary.get('original').strip()
            row=[text,index,offset,max_bytes]
        if (not is_ascii(text)) and (text not in blacklist):
            spreadsheet.append(row)

    return spreadsheet



#column is an index, so reference it as column-1
def get_translated_data_from_row(row,column=0,minimum_column_index=4):
    if (column != 0) and (column < len(row)):
        assert column >= minimum_column_index
        data=row[column].strip()
        if data != '': return data
    for i in reversed(row[minimum_column_index:]):
        data=i.strip()
        if data != '': return data
    return ''

def postprocess(line):
    line=line.strip()
    return line

def insert(input_json_filename,spreadsheet_filename,csv_dialect=None,column=0):
    """ updated_json=insert(input_json_filename=input.inputfile,spreadsheet_filename=input.spreadsheet,csv_dialect=input.csv_dialect)
    """

    input_json=read_file(input_json_filename,as_json=True)
    if input_json is None:
        print('unable to read',input_json_filename)
        raise Exception()

    if 'strings' not in input_json:
        print('unrecognized json format')
        return []
    strings=input_json['strings']

    spreadsheet=read_spreadsheet(spreadsheet_path=spreadsheet_filename,csv_dialect=csv_dialect)
    if spreadsheet is None:
        print('unable to read',spreadsheet_filename)
        raise Exception()

    #headers_dialogue=['untranslated_texts','entry_idx','string_idx','event_id']
    #headers_gsdb_strings=['untranslated_texts','index','offset','max_bytes']

    if strings[0].get('entry_idx') is not None:
        #headers=headers_dialogue
        dialogue_mode=True
    else:
        #headers=headers_gsdb_strings
        dialogue_mode=False

    for row_number,row_data in enumerate(spreadsheet):
        if row_number == 0: continue
        text_from_spreadsheet=row_data[0]
        translated_data_from_spreadsheet=get_translated_data_from_row(row_data,column=column,minimum_column_index=4)
        if translated_data_from_spreadsheet == '':
            continue

        if dialogue_mode:
            entry_index_spreadsheet=int(row_data[1])
            string_index_spreadsheet=int(row_data[2])
            event_index_spreadsheet=int(row_data[3])

            for text_dictionary in strings:
                entry_index_json=int(text_dictionary.get('entry_idx'))
                string_index_json=int(text_dictionary.get('string_idx'))
                event_index_json=int(text_dictionary.get('event_idx'))
                if entry_index_spreadsheet != entry_index_json:
                    continue
                if string_index_spreadsheet != string_index_json:
                    continue
                if event_index_spreadsheet != event_index_json:
                    continue

                raw_text_from_json=text_dictionary.get('original').strip()
                assert text_from_spreadsheet == raw_text_from_json

                text_dictionary['translation']=text_dictionary['translation'].replace(raw_text_from_json,translated_data_from_spreadsheet)
                break
        else:
            index_spreadsheet=int(row_data[1])
            offset_spreadsheet=int(row_data[2])
            max_bytes_spreadsheet=int(row_data[3])

            for text_dictionary in strings:
                index_json=int(text_dictionary.get('index'))
                offset_json=int(text_dictionary.get('offset'))
                max_bytes_json=int(text_dictionary.get('max_bytes'))
                if index_spreadsheet != index_json:
                    continue
                if offset_spreadsheet != offset_json:
                    continue
                if max_bytes_spreadsheet != max_bytes_json:
                    continue

                raw_text_from_json=text_dictionary.get('original').strip()
                assert text_from_spreadsheet == raw_text_from_json

                text_dictionary['translation']=text_dictionary['translation'].replace(raw_text_from_json,translated_data_from_spreadsheet)
                break

    return input_json


def main():
    # https://docs.python.org/3/library/argparse.html
    # https://www.gnu.org/prep/standards/standards.html#Command_002dLine-Interfaces
    cli=argparse.ArgumentParser(description='Extract and insert text into kirikiriz .txt.scn files after they have been extracted from archive.xp3. Any folders in the paths must already exist. For usage, \'python tool.py -h\' version='+__version__)
    cli.add_argument('mode',help='must be extract or insert')
    cli.add_argument('inputfile',help='the source file or folder to extract strings from and insert them into')
    cli.add_argument('-s','--spreadsheet', help='the file name to read and write the extracted strings to and from, the first row is reserved for column headers, must be .csv if pyexcel is not installed with \'python -m pip install pyexcel pyexcel-xls pyexcel-xlsx pyexcel-ods pyexcel-ods3 pyexcel-odsr openpyxl==3.0.10\', for batches, add the spreadsheet extension to change the output format '+str(known_spreadsheet_extensions))
    cli.add_argument('-cn','--character_names', help='a .csv or spreadsheet mapping the raw character name to a translation, the first row is reserved for column headers')
    cli.add_argument('-o','--output', help='the output file name or folder for the resulting file, only used for mode=insert')
    cli.add_argument('-c','--column', help='the column number in the spreadsheet to use as replacements, only used for mode=insert, starts from 1, column A is the 1st column, so enter 1, column D is the 4th column, so enter 4, column C is 3, 0 is a special flag that uses the right most column, default is '+str(0), default=0, type=int)
    cli.add_argument('-w','--wordwrap', help='word wrap setting, enter the number of characters per line, word wrap assumes a maximum of 3 lines, default is '+str(50)+' sane values are 30-80 characters, if accurate word wrapping is enabled using wordwrap_font, this is instead interpreted as the maximum pixel length, sane values for max pixel length are 400+', default=50, type=int)
    cli.add_argument('-wf','--wordwrap_font', help='optional, enable accurate word wrapping by providing the full path to the .ttf font that will display the text, requires the PIL image library at >= 8.x, python -m pip install pillow')
    cli.add_argument('-wfe','--wordwrap_font_encoding', help='optional, the encoding of the .ttf font, can be unic for Unicode, sjis for shift-jis, big5, see more options at https://hugovk-pillow.readthedocs.io/en/stable/reference/ImageFont.html#PIL.ImageFont.truetype')
    cli.add_argument('-wp','--wordwrap_point_size', help='optional, for accurate word wrapping, this is the point size to use with the .ttf font, default is '+str(12), default=12, type=int)
    cli.add_argument('-cd','--csv_dialect', help='specify the csv dialect when reading spreadsheet.csv files, normal settings are used otherwise, ignored for non .csv formats, valid options are unix, excel, excel-tab')
    cli.add_argument('-v','--version', help='print version information', action='store_true')
    cli.add_argument('-d','--debug', help='print debug information', action='store_true')
    cli.add_argument('-t','--test', help='read input and process data but do not write any output', action='store_true')

    # https://stackoverflow.com/questions/4042452/display-help-message-with-python-argparse-when-script-is-called-without-any-argu
    if len(sys.argv) == 1:
        cli.parse_args(['--help'])

    for i in sys.argv:
        if i.lower() in version_array:
            print(os.path.basename(sys.argv[0]),__version__)
            exit(0)

    for i in sys.argv:
        if i.lower() in help_array:
            cli.parse_args(['--help'])

    #input is a class with a lot of variables that can be accessed as input.mode input.spreadsheet and so forth
    input=cli.parse_args()
    global debug
    debug=input.debug

    if not os.path.isfile(input.inputfile):
        print(input.inputfile,'does not exist')
        exit(1)

    modes=['extract','insert']
    input.mode=input.mode.lower()
    if input.mode not in modes:
        print('unrecognized mode',input.mode,'must be in',modes)
        exit(1)

    if not input.spreadsheet:
        input.spreadsheet=input.inputfile+'.csv'
    else:
        assert os.path.sifile(input.spreadsheet)

    if not input.output:
        input.output=input.inputfile+'.translated.json'

    if input.mode == 'extract':
        if os.path.isfile(input.spreadsheet):
            print(input.spreadsheet,'already exists')
            exit(1)
    elif input.mode == 'insert':
        if not os.path.isfile(input.spreadsheet):
            print(input.spreadsheet,'does not exists')
            exit(1)
        if os.path.isfile(input.output):
            print(input.output,'already exists')
            exit(1)

    if input.mode == 'extract':
        extracted_lines_as_spreadsheet=extract(input_json_filename=input.inputfile)
        if not extracted_lines_as_spreadsheet or (len(extracted_lines_as_spreadsheet) < 2): #len is 1 due to header
            print('no texts found')
            exit(0)

        if debug:
            print(extracted_lines_as_spreadsheet)

        if not input.test:
            write_spreadsheet(input.spreadsheet,extracted_lines_as_spreadsheet,csv_dialect=input.csv_dialect)

    elif input.mode == 'insert':
        updated_json=insert(input_json_filename=input.inputfile,spreadsheet_filename=input.spreadsheet,csv_dialect=input.csv_dialect,column=input.column)
        if not updated_json:
            print('error inserting texts')
            exit(1)

        if not input.test:
            write_file(input.output,updated_json,as_json=True)


if __name__ == '__main__':
    main()

personA_convert_json_to_csv.py is a rush job based on the tool Shisaye posted.

The next step would be to go back and look at how the tools Shisaye posted are unpacking the binary code and re-write them while understanding the script syntax. Then, it should be possible to group the lines and order them, and find out who the speaker is so the exported .csv's have more context than just being an unordered random dump of translatable strings.

For now at least, this means the game is now translatable, which is a big improvement over yesterday!
 
Last edited:
A huge thank you to Shisaye for creating this tool!!! And also to Entai2965 for trying to make this gem of a game playable!!!.
Hopefully there will be good news in the future. Thanks again to both of you!!!.
:akazukin_yahoo::akazukin_thanks:
 
Last edited: