#
# SegmentOperations.py
#
# Class: SegmentOperations
# Segment operations for FieldWorks Language Explorer projects
# via SIL Language and Culture Model (LCM) API.
#
# Platform: Python.NET
# FieldWorks Version 9+
#
# Copyright 2025
#
import logging
import clr
clr.AddReference("System")
from SIL.LCModel import (
ISegment,
ISegmentFactory,
IStTxtPara,
)
from SIL.LCModel.Core.KernelInterfaces import ITsString
from SIL.LCModel.Core.Text import TsStringUtils
from ..FLExProject import (
FP_NullParameterError,
FP_ParameterError,
FP_ReadOnlyError,
)
from ..BaseOperations import BaseOperations, OperationsMethod, wrap_enumerable
from ..lcm_casting import cast_to_concrete
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# MergeSegments translation_policy constants
# Use these instead of bare strings to avoid silent routing to the wrong branch.
# ---------------------------------------------------------------------------
TRANSLATION_POLICY_MIGRATE = "migrate"
TRANSLATION_POLICY_DISCARD = "discard"
TRANSLATION_POLICY_REJECT = "reject"
_VALID_TRANSLATION_POLICIES = {
TRANSLATION_POLICY_MIGRATE,
TRANSLATION_POLICY_DISCARD,
TRANSLATION_POLICY_REJECT,
}
[docs]
class SegmentOperations(BaseOperations):
"""
This class provides operations for managing segments in a FieldWorks project.
Segments represent the units of text analysis in FLEx, typically sentences
or clauses within paragraphs. Each segment can have baseline text, analyses,
translations (free and literal), and notes.
The write path follows the LCM idiom:
1. Edit IStTxtPara.Contents (via TsStringBuilder) first.
2. Assign builder.GetString() to para.Contents - this fires ContentsSideEffects
which triggers AnalysisAdjuster to reconcile segment offsets automatically.
3. Then update SegmentsOS (Add/Insert/Remove) as needed.
Usage::
from flexicon import FLExProject
project = FLExProject()
project.OpenProject("my project", writeEnabled=True)
# Get all segments in a paragraph
para = project.Object(para_hvo)
for segment in project.Segments.GetAll(para):
baseline = project.Segments.GetBaselineText(segment)
print(baseline)
# Append a new sentence to a paragraph
new_seg = project.Segments.AppendSentence(para, "A new sentence.")
# Split a segment at a character offset within that segment
seg1, seg2 = project.Segments.SplitSegment(new_seg, 5)
# Merge two adjacent segments (migrates translations by default)
merged = project.Segments.MergeSegments(seg1, seg2)
# Set translations on a segment
project.Segments.SetFreeTranslation(merged, "In the beginning...")
project.Segments.SetLiteralTranslation(merged, "In-the beginning...")
# Reparse a paragraph from its Contents (use only to repair corruption)
project.Segments.ReparseParagraph(para)
project.CloseProject()
"""
def __init__(self, project):
"""
Initialize SegmentOperations with a FLExProject instance.
Args:
project: The FLExProject instance to operate on.
"""
super().__init__(project)
def _GetSequence(self, parent):
"""Specify which sequence to reorder: a paragraph's segments.
``parent`` must be an IStTxtPara -- the same type GetAll()/
AppendSentence() already require via __GetParagraphObject().
AnalysesRS has its own dedicated API (SetAnalysis/ReplaceAnalysis/
InsertAnalysis/AppendAnalysis/RemoveAnalysis, issue #215) and must
not be targeted by Sort/MoveUp/MoveDown/MoveToIndex.
"""
return parent.SegmentsOS
def __WSHandle(self, wsHandle):
"""
Internal helper for writing system handles (analysis).
Args:
wsHandle: Writing system handle or None for default analysis WS.
Returns:
int: The writing system handle to use.
"""
if wsHandle is None:
return self.project.project.DefaultAnalWs
return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultAnalWs)
def __WSHandleVern(self, wsHandle):
"""
Internal helper for writing system handles (vernacular).
Args:
wsHandle: Writing system handle or None for default vernacular WS.
Returns:
int: The writing system handle to use.
"""
if wsHandle is None:
return self.project.project.DefaultVernWs
return self.project._FLExProject__WSHandle(wsHandle, self.project.project.DefaultVernWs)
def __GetParagraphObject(self, para_or_hvo):
"""
Internal helper to get paragraph object from HVO or object.
Args:
para_or_hvo: IStTxtPara object or HVO integer.
Returns:
IStTxtPara: The paragraph object.
"""
if isinstance(para_or_hvo, int):
return cast_to_concrete(self.project.Object(para_or_hvo))
return cast_to_concrete(para_or_hvo)
def __GetSegmentObject(self, segment_or_hvo):
"""
Internal helper to get segment object from HVO or object.
Args:
segment_or_hvo: ISegment object or HVO integer.
Returns:
ISegment: The segment object.
"""
if isinstance(segment_or_hvo, int):
return cast_to_concrete(self.project.Object(segment_or_hvo))
return cast_to_concrete(segment_or_hvo)
def __GetSegmentFactory(self):
"""Return the ISegmentFactory from the service locator."""
return self.project.project.ServiceLocator.GetService(ISegmentFactory)
def __GetAnalysisObject(self, analysis_or_hvo):
"""
Internal helper to get an IAnalysis object from HVO or object.
Args:
analysis_or_hvo: An IAnalysis object (IWfiWordform, IWfiAnalysis,
IWfiGloss, or IPunctuationForm) or its HVO integer.
Returns:
``IAnalysis``: The analysis object. The HVO path returns
``project.Object(hvo)`` without ``cast_to_concrete`` (polymorphic
wordform / analysis / gloss / punctuation types). Callers that
need concrete analysis interfaces must cast per issue #212; this
helper stays deliberately generic (issue #494).
"""
if isinstance(analysis_or_hvo, int):
return self.project.Object(analysis_or_hvo)
return analysis_or_hvo
# ========== READ METHODS ==========
@wrap_enumerable
@OperationsMethod
def GetAll(self, paragraph_or_hvo):
"""
Get all segments in a paragraph.
Args:
paragraph_or_hvo: The IStTxtPara object or HVO.
Returns:
EnumerableWrapper[ISegment]: Each segment in the paragraph.
Raises:
FP_NullParameterError: If paragraph_or_hvo is None.
Example:
>>> para = project.Object(para_hvo)
>>> for segment in project.Segments.GetAll(para):
... text = project.Segments.GetBaselineText(segment)
... print(text)
In the beginning God created the heavens and the earth.
Now the earth was formless and empty.
See Also:
GetBaselineText, GetFreeTranslation, GetAnalyses
"""
self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo")
para_obj = self.__GetParagraphObject(paragraph_or_hvo)
segments = list(para_obj.SegmentsOS)
for segment in segments:
yield segment
@OperationsMethod
def GetAnalyses(self, segment_or_hvo):
"""
Get all analyses (wordforms and their linguistic analyses) for a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
list: List of IAnalysis objects representing the wordforms in the segment.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> analyses = project.Segments.GetAnalyses(segment)
>>> print(f"Segment has {len(analyses)} wordforms")
Segment has 5 wordforms
See Also:
GetAll, GetBaselineText
Notes:
Each analysis can be an IWfiWordform, IWfiAnalysis, IWfiGloss, or
IPunctuationForm object. IPunctuationForm is a legitimate member
of this collection (punctuation tokens implement IAnalysis too),
not an error case -- callers must not assume every entry is a word.
These represent different levels of linguistic analysis for each word.
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
analyses = list(segment_obj.AnalysesRS)
return analyses
@OperationsMethod
def GetGloss(self, analysis_or_hvo, wsHandle=None):
"""
Get the gloss text for a segment analysis token of any type.
``GetAnalyses()`` returns polymorphic ``IAnalysis`` objects -- a mix of
``IWfiWordform`` (unanalyzed), ``IWfiAnalysis`` (analyzed, no chosen
gloss), ``IWfiGloss`` (fully glossed), and ``IPunctuationForm``. Only
``IWfiGloss`` tokens carry a chosen word gloss; reading it off the wrong
subtype (or off an un-cast ``IAnalysis`` reference) silently yields ''
(issue #212). This helper dispatches on the concrete type so callers
stop hand-rolling ``IWfiGloss(tok)`` casts in interlinear loops.
Args:
analysis_or_hvo: An IAnalysis token (from GetAnalyses) or its HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The gloss text for an IWfiGloss token; '' for token types that
carry no chosen word gloss (wordform, bare analysis, punctuation).
Raises:
FP_NullParameterError: If analysis_or_hvo is None.
Example:
>>> for tok in project.Segments.GetAnalyses(segment):
... gloss = project.Segments.GetGloss(tok, eng_ws) # no cast!
... print(gloss)
See Also:
GetAnalyses, WfiAnalyses.GetCategoryAbbrev, WfiAnalyses.GetMorphemeBundles
"""
self._ValidateParam(analysis_or_hvo, "analysis_or_hvo")
if isinstance(analysis_or_hvo, int):
token = self.project.Object(analysis_or_hvo)
else:
token = analysis_or_hvo
# Only IWfiGloss carries a chosen word gloss. Dispatch on ClassName --
# pythonnet isinstance() against IWfiGloss is False for the base
# IAnalysis-typed refs GetAnalyses returns (issue #212). WfiGlosses
# .GetForm does the concrete IWfiGloss(...) cast internally.
if getattr(token, "ClassName", None) == "WfiGloss":
return self.project.WfiGlosses.GetForm(token, wsHandle)
return ""
@OperationsMethod
def GetBaselineText(self, segment_or_hvo):
"""
Get the baseline text of a segment (the original text in the vernacular language).
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
str: The baseline text, or empty string if not set.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> text = project.Segments.GetBaselineText(segment)
>>> print(text)
In the beginning God created the heavens and the earth.
See Also:
GetFreeTranslation, GetLiteralTranslation, GetAnalyses
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
# ISegment.BaselineText is ITsString (WS already resolved by LCM),
# not ITsMultiString. Calling .get_String(ws) on it raises
# AttributeError at runtime. See issue #170.
return segment_obj.BaselineText.Text or ""
@OperationsMethod
def SetBaselineText(self, segment_or_hvo, text, wsHandle=None):
"""
Set the baseline text of a segment by editing the owning paragraph Contents.
The correct write idiom is to edit IStTxtPara.Contents via a TsStrBldr;
ContentsSideEffects then fires AnalysisAdjuster.AdjustAnalysis which
reconciles segment offsets automatically.
Args:
segment_or_hvo: The ISegment object or HVO.
text: The baseline text to set.
wsHandle: Optional writing system handle. Defaults to vernacular WS.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
FP_ParameterError: If segment has no owning paragraph.
Example:
>>> segment = segments[0]
>>> project.Segments.SetBaselineText(segment, "In the beginning...")
See Also:
GetBaselineText, SetFreeTranslation, SetLiteralTranslation
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
ws = self.__WSHandleVern(wsHandle)
para = self.GetOwningParagraph(segment_obj)
begin = segment_obj.BeginOffset
end = segment_obj.EndOffset
# The builder work is pure in-memory string assembly on a TsStrBldr;
# only the Contents assignment touches the LCM, so that is what the
# bracket covers. Reassigning Contents fires ContentsSideEffects ->
# AnalysisAdjuster, which re-derives the paragraph's segments, so the
# unit of work has to span that cascade rather than just the setter.
bldr = para.Contents.GetBldr()
new_run = TsStringUtils.MakeString(text, ws)
bldr.ReplaceTsString(begin, end, new_run)
with self._TransactionCM("Set segment baseline text"):
para.Contents = bldr.GetString()
@OperationsMethod
def GetFreeTranslation(self, segment_or_hvo, wsHandle=None):
"""
Get the free translation for a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The free translation text, or empty string if not set.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> trans = project.Segments.GetFreeTranslation(segment)
>>> print(trans)
In the beginning God created the heavens and the earth.
See Also:
SetFreeTranslation, GetLiteralTranslation, GetBaselineText
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
ws = self.__WSHandle(wsHandle)
if segment_obj.FreeTranslation:
text = ITsString(segment_obj.FreeTranslation.get_String(ws)).Text
return text or ""
return ""
@OperationsMethod
def SetFreeTranslation(self, segment_or_hvo, text, wsHandle=None):
"""
Set the free translation for a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
text: The free translation text to set.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> project.Segments.SetFreeTranslation(segment, "In the beginning God created...")
See Also:
GetFreeTranslation, SetLiteralTranslation, SetBaselineText
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
ws = self.__WSHandle(wsHandle)
with self._TransactionCM("Set free translation"):
mkstr = TsStringUtils.MakeString(text, ws)
segment_obj.FreeTranslation.set_String(ws, mkstr)
@OperationsMethod
def GetLiteralTranslation(self, segment_or_hvo, wsHandle=None):
"""
Get the literal (word-for-word) translation of a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Returns:
str: The literal translation text, or empty string if not set.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> trans = project.Segments.GetLiteralTranslation(segment)
>>> print(trans)
In-the beginning God created the-heavens and-the-earth
See Also:
SetLiteralTranslation, GetFreeTranslation, GetBaselineText
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
ws = self.__WSHandle(wsHandle)
if segment_obj.LiteralTranslation:
text = ITsString(segment_obj.LiteralTranslation.get_String(ws)).Text
return text or ""
return ""
@OperationsMethod
def SetLiteralTranslation(self, segment_or_hvo, text, wsHandle=None):
"""
Set the literal (word-for-word) translation of a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
text: The literal translation text to set.
wsHandle: Optional writing system handle. Defaults to analysis WS.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> project.Segments.SetLiteralTranslation(segment, "In-the beginning God created...")
See Also:
GetLiteralTranslation, SetFreeTranslation, SetBaselineText
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
ws = self.__WSHandle(wsHandle)
with self._TransactionCM("Set literal translation"):
mkstr = TsStringUtils.MakeString(text, ws)
segment_obj.LiteralTranslation.set_String(ws, mkstr)
@OperationsMethod
def GetNotes(self, segment_or_hvo):
"""
Get all notes associated with a segment.
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
list: List of note objects associated with the segment.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> notes = project.Segments.GetNotes(segment)
>>> print(f"Segment has {len(notes)} notes")
Segment has 2 notes
See Also:
GetAll, GetBaselineText, GetFreeTranslation
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
if hasattr(segment_obj, "NotesOS"):
notes = list(segment_obj.NotesOS)
return notes
return []
# ========== WRITE METHODS ==========
@OperationsMethod
def AppendSentence(self, paragraph_or_hvo, text, wsHandle=None, guid=None):
"""
Append a new sentence to a paragraph, creating a new segment.
Edits IStTxtPara.Contents first (firing AnalysisAdjuster on existing
segments), then adds the new ISegment to SegmentsOS.
A sentence terminator is automatically inserted before the new text
when the current paragraph Contents is non-empty and does not
already end with one of {. ! ?}. The test is applied to the last
NON-WHITESPACE character, and the terminator is anchored there, so
trailing whitespace in the existing Contents is preserved rather
than written over (#242 C12). What gets inserted depends on whether
that trailing whitespace is present: '. ' when it is absent (the
period supplies its own separating space), or '.' alone when it is
present (the existing whitespace already separates the sentences).
When the paragraph is empty, the text is written directly. See the
Note: below for the full invariant.
Args:
paragraph_or_hvo: The IStTxtPara object or HVO.
text: The sentence text to append. Must be non-empty.
Note: leading/trailing whitespace in the value is preserved
verbatim (#242); a value that is entirely whitespace still
raises FP_ParameterError.
wsHandle: Optional writing system handle. Defaults to project
default vernacular WS.
guid (optional): GUID to assign to the new segment, as a
``System.Guid`` or string. Use this when REPRODUCING a
segment from another project so it keeps its original
identity. None (the default) mints a fresh GUID.
Returns:
ISegment: The newly created segment object.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If paragraph_or_hvo or text is None.
FP_ParameterError: If text is empty or paragraph is invalid.
Example:
>>> para = project.Object(para_hvo)
>>> seg = project.Segments.AppendSentence(para, "A new sentence.")
>>> print(project.Segments.GetBaselineText(seg))
A new sentence.
See Also:
SplitSegment, MergeSegments, ReparseParagraph
Note:
AppendSentence may INSERT at the join boundary between the
existing contents and the appended text; it never DELETES
there. If the existing contents end in whitespace, that
whitespace is preserved and reused as the sentence separator
(#242).
"""
self._EnsureWriteEnabled()
self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo")
self._ValidateParam(text, "text")
# NOTE (#242): .strip() here is a THROWAWAY used only for the
# emptiness check. The caller's ORIGINAL payload (text_str) is what
# is persisted below -- leading/trailing/internal whitespace is
# preserved, not stripped. Non-str branch is str(text) with NO
# trailing .strip() (per the ruling, deliberately removed).
text_str = text if isinstance(text, str) else str(text)
if not text_str.strip():
raise FP_ParameterError("text cannot be empty")
para = self.__GetParagraphObject(paragraph_or_hvo)
ws = self.__WSHandleVern(wsHandle)
with self._TransactionCM("Append sentence"):
# --- Step 1: edit Contents ---
bldr = para.Contents.GetBldr()
current_length = para.Contents.Length
if current_length > 0:
# NOTE (#242 C12): AppendSentence may INSERT at the join
# boundary. It may never DELETE at the join boundary. Now
# that Create/SetText/InsertAt preserve caller-supplied
# whitespace (#242), para.Contents can legitimately end in
# whitespace, so the RAW last character is no longer a
# reliable signal of "already terminated". rstrip() below
# is used ONLY to compute an index (`anchor`); the
# stripped string it returns is never written and never
# passed to MakeString -- ZERO characters are removed by
# this branch in any case.
raw = para.Contents.Text or ""
# anchor: index one past the last non-whitespace character.
anchor = len(raw.rstrip())
# trail: count of trailing whitespace characters.
trail = current_length - anchor
if raw == "" and current_length > 0:
# Degenerate case (Contents.Text is None/empty while
# Length > 0) -- preserve today's behaviour exactly;
# do not route this through the new logic below.
terminator = TsStringUtils.MakeString(". ", ws)
bldr.ReplaceTsString(current_length, current_length, terminator)
insert_at = current_length + 2
elif anchor == 0:
# Contents is non-empty but entirely whitespace -- no
# sentence to terminate. Insert no terminator and no
# separator; place the new text at the end.
insert_at = current_length
elif raw[anchor - 1] in (".", "!", "?"):
# Already terminated at the last non-whitespace char.
if trail > 0:
# The existing trailing whitespace IS the
# separator -- insert nothing.
insert_at = current_length
else:
# Unchanged from today: insert a space separator.
sep = TsStringUtils.MakeString(" ", ws)
bldr.ReplaceTsString(current_length, current_length, sep)
insert_at = current_length + 1
else:
# Not yet terminated -- needs a terminator.
if trail > 0:
# Insert "." ONLY (one character, NOT ". ") at the
# anchor -- the existing trailing whitespace
# becomes the separator after it.
terminator = TsStringUtils.MakeString(".", ws)
bldr.ReplaceTsString(anchor, anchor, terminator)
insert_at = current_length + 1
else:
# Unchanged from today: insert ". " as terminator.
terminator = TsStringUtils.MakeString(". ", ws)
bldr.ReplaceTsString(current_length, current_length, terminator)
insert_at = current_length + 2
new_text_ts = TsStringUtils.MakeString(text_str, ws)
bldr.ReplaceTsString(insert_at, insert_at, new_text_ts)
else:
# Paragraph is empty — write text directly
new_text_ts = TsStringUtils.MakeString(text_str, ws)
bldr.ReplaceTsString(0, 0, new_text_ts)
# Assign Contents — fires ContentsSideEffects -> AnalysisAdjuster on
# existing segments, setting their BeginOffset/EndOffset correctly.
para.Contents = bldr.GetString()
# --- Step 2: add new segment to SegmentsOS ---
factory = self.__GetSegmentFactory()
seg = self._CreateWithGuid(factory, guid, "ISegment")
para.SegmentsOS.Add(seg)
# AnalysisAdjuster (fired by the Contents setter above) will set
# seg.BeginOffset / seg.EndOffset when the paragraph is re-parsed.
return seg
@OperationsMethod
def Delete(self, segment_or_hvo):
"""
Delete a segment from its paragraph.
Removes the segment from its parent paragraph's segment collection.
All analyses, translations, and notes associated with the segment
are also deleted.
Args:
segment_or_hvo: The ISegment object or HVO.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
FP_ParameterError: If segment has no owning paragraph.
Example:
>>> segment = segments[0]
>>> project.Segments.Delete(segment)
See Also:
AppendSentence, Exists, MergeSegments
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
owner = self._GetTypedOwner(segment_obj)
if owner is None:
raise FP_ParameterError("Segment has no owning paragraph")
with self._TransactionCM("Delete segment"):
owner.SegmentsOS.Remove(segment_obj)
@OperationsMethod
def SetAnalysis(self, segment_or_hvo, index, analysis_or_hvo):
"""
Replace the analysis token at a given index in a segment's AnalysesRS.
Writes directly to ``ISegment.AnalysesRS`` (a reference sequence),
which previously required dropping to raw LCM reflection (issue #215).
Args:
segment_or_hvo: The ISegment object or HVO.
index: The 0-based index within AnalysesRS to replace.
analysis_or_hvo: The replacement IAnalysis object (IWfiWordform,
IWfiAnalysis, IWfiGloss, or IPunctuationForm) or its HVO.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None.
FP_ParameterError: If index is out of range for AnalysesRS.
Example:
>>> segment = segments[0]
>>> analyses = project.Segments.GetAnalyses(segment)
>>> project.Segments.SetAnalysis(segment, 0, replacement_gloss)
See Also:
GetAnalyses, ReplaceAnalysis, InsertAnalysis
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
self._ValidateParam(analysis_or_hvo, "analysis_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
analysis_obj = self.__GetAnalysisObject(analysis_or_hvo)
count = segment_obj.AnalysesRS.Count
if not isinstance(index, int) or index < 0 or index >= count:
raise FP_ParameterError(
f"index must be between 0 and {count - 1} (inclusive); got {index!r}"
)
with self._TransactionCM("Set segment analysis"):
segment_obj.AnalysesRS[index] = analysis_obj
@OperationsMethod
def ReplaceAnalysis(self, segment_or_hvo, old_analysis_or_hvo, new_analysis_or_hvo):
"""
Find an analysis token in a segment's AnalysesRS and replace it.
Writes directly to ``ISegment.AnalysesRS`` (a reference sequence),
which previously required dropping to raw LCM reflection (issue #215).
Args:
segment_or_hvo: The ISegment object or HVO.
old_analysis_or_hvo: The existing IAnalysis object or HVO to
locate within AnalysesRS.
new_analysis_or_hvo: The IAnalysis object or HVO to put in its place.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo, old_analysis_or_hvo, or
new_analysis_or_hvo is None.
FP_ParameterError: If old_analysis_or_hvo is not found in
AnalysesRS.
Example:
>>> segment = segments[0]
>>> analyses = project.Segments.GetAnalyses(segment)
>>> project.Segments.ReplaceAnalysis(segment, analyses[2], new_gloss)
See Also:
GetAnalyses, SetAnalysis, InsertAnalysis
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
self._ValidateParam(old_analysis_or_hvo, "old_analysis_or_hvo")
self._ValidateParam(new_analysis_or_hvo, "new_analysis_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
old_obj = self.__GetAnalysisObject(old_analysis_or_hvo)
new_obj = self.__GetAnalysisObject(new_analysis_or_hvo)
# Membership by HVO (issue #528). AnalysesRS can yield interface views
# while __GetAnalysisObject may return a different Python wrapper for
# the same token; ``in`` / list.index then false-negative. Elements
# without an HVO never match -- they are skipped, not dereferenced.
old_hvo = old_obj.Hvo
index = None
for i, token in enumerate(segment_obj.AnalysesRS):
if getattr(token, "Hvo", None) == old_hvo:
index = i
break
if index is None:
raise FP_ParameterError(
"old_analysis_or_hvo was not found in the segment's AnalysesRS"
)
with self._TransactionCM("Replace segment analysis"):
segment_obj.AnalysesRS[index] = new_obj
@OperationsMethod
def InsertAnalysis(self, segment_or_hvo, index, analysis_or_hvo):
"""
Insert an analysis token at a given index in a segment's AnalysesRS.
Writes directly to ``ISegment.AnalysesRS`` (a reference sequence),
which previously required dropping to raw LCM reflection (issue #215).
Args:
segment_or_hvo: The ISegment object or HVO.
index: The 0-based index at which to insert. May equal the
current AnalysesRS.Count to append at the end.
analysis_or_hvo: The IAnalysis object or HVO to insert.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None.
FP_ParameterError: If index is out of range for AnalysesRS.
Example:
>>> segment = segments[0]
>>> project.Segments.InsertAnalysis(segment, 0, new_wordform)
See Also:
GetAnalyses, SetAnalysis, ReplaceAnalysis, AppendAnalysis
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
self._ValidateParam(analysis_or_hvo, "analysis_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
analysis_obj = self.__GetAnalysisObject(analysis_or_hvo)
count = segment_obj.AnalysesRS.Count
if not isinstance(index, int) or index < 0 or index > count:
raise FP_ParameterError(
f"index must be between 0 and {count} (inclusive); got {index!r}"
)
with self._TransactionCM("Insert segment analysis"):
segment_obj.AnalysesRS.Insert(index, analysis_obj)
@OperationsMethod
def AppendAnalysis(self, segment_or_hvo, analysis_or_hvo):
"""
Append an analysis token to the end of a segment's AnalysesRS.
Convenience wrapper around ``InsertAnalysis`` at
``AnalysesRS.Count`` (issue #215).
Args:
segment_or_hvo: The ISegment object or HVO.
analysis_or_hvo: The IAnalysis object or HVO to append.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo or analysis_or_hvo is None.
Example:
>>> segment = segments[0]
>>> project.Segments.AppendAnalysis(segment, new_gloss)
See Also:
InsertAnalysis, SetAnalysis, RemoveAnalysis
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
self._ValidateParam(analysis_or_hvo, "analysis_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
analysis_obj = self.__GetAnalysisObject(analysis_or_hvo)
with self._TransactionCM("Append segment analysis"):
segment_obj.AnalysesRS.Add(analysis_obj)
@OperationsMethod
def RemoveAnalysis(self, segment_or_hvo, index):
"""
Remove the analysis token at a given index from a segment's AnalysesRS.
Args:
segment_or_hvo: The ISegment object or HVO.
index: The 0-based index within AnalysesRS to remove.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
FP_ParameterError: If index is out of range for AnalysesRS.
Example:
>>> segment = segments[0]
>>> project.Segments.RemoveAnalysis(segment, 0)
See Also:
InsertAnalysis, AppendAnalysis, SetAnalysis
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
count = segment_obj.AnalysesRS.Count
if not isinstance(index, int) or index < 0 or index >= count:
raise FP_ParameterError(
f"index must be between 0 and {count - 1} (inclusive); got {index!r}"
)
with self._TransactionCM("Remove segment analysis"):
segment_obj.AnalysesRS.RemoveAt(index)
@OperationsMethod
def SplitSegment(self, segment_or_hvo, offset_within_segment):
"""
Split a segment at a character offset measured from the start of the segment.
Inserts a sentence terminator ('. ') into IStTxtPara.Contents at the
absolute position corresponding to the split, then adds the new ISegment
to SegmentsOS. AnalysisAdjuster (fired by the Contents setter) shifts
all subsequent segment offsets right by 2.
Args:
segment_or_hvo: The ISegment object or HVO to split.
offset_within_segment: Character offset from the segment's BeginOffset
where the split occurs. Must satisfy:
0 < offset_within_segment < (seg.EndOffset - seg.BeginOffset).
Returns:
tuple: (seg, new_seg) - the original (now shorter) segment and the
newly inserted segment that follows it.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
FP_ParameterError: If offset_within_segment is out of range or
segment has no owning paragraph.
Example:
>>> seg = segments[0]
>>> print(project.Segments.GetBaselineText(seg))
In the beginning God created the heavens.
>>> seg1, seg2 = project.Segments.SplitSegment(seg, 17)
>>> print(project.Segments.GetBaselineText(seg1))
In the beginning
>>> print(project.Segments.GetBaselineText(seg2))
God created the heavens.
See Also:
MergeSegments, AppendSentence, ReparseParagraph
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
seg = self.__GetSegmentObject(segment_or_hvo)
seg_len = seg.EndOffset - seg.BeginOffset
if offset_within_segment is None or not (0 < offset_within_segment < seg_len):
raise FP_ParameterError(
f"offset_within_segment must be between 1 and {seg_len - 1} "
f"(exclusive); got {offset_within_segment}"
)
para = self.GetOwningParagraph(seg)
absolute_offset = seg.BeginOffset + offset_within_segment
# --- Step 1: edit Contents ---
# Resolve the paragraph's vernacular WS from the existing text run so
# the inserted terminator uses the same writing system as its neighbours.
ws = self.__WSHandleVern(None)
with self._TransactionCM("Split segment"):
bldr = para.Contents.GetBldr()
terminator = TsStringUtils.MakeString(". ", ws)
bldr.ReplaceTsString(absolute_offset, absolute_offset, terminator)
# Assign — fires AnalysisAdjuster which shifts seg3+ offsets right by 2
para.Contents = bldr.GetString()
# --- Step 2: insert new segment into SegmentsOS ---
idx = para.SegmentsOS.IndexOf(seg)
factory = self.__GetSegmentFactory()
new_seg = factory.Create()
para.SegmentsOS.Insert(idx + 1, new_seg)
return (seg, new_seg)
@OperationsMethod
def MergeSegments(self, seg1_or_hvo, seg2_or_hvo, translation_policy="migrate"):
"""
Merge two adjacent segments into one, removing the inter-segment boundary.
Edits IStTxtPara.Contents first (removing the terminator+whitespace
between the two segments), then removes seg2 from SegmentsOS.
AnalysisAdjuster (fired by the Contents setter) shifts all subsequent
segment offsets left automatically.
Args:
seg1_or_hvo: The first (earlier) ISegment object or HVO.
seg2_or_hvo: The second (later) ISegment object or HVO. Must be
immediately after seg1 in SegmentsOS.
translation_policy (str): One of:
- TRANSLATION_POLICY_MIGRATE / 'migrate' (default): Concatenate
seg2's FreeTranslation and LiteralTranslation into seg1's,
joined by ' / ' per writing system that has content. Also moves
all Notes from seg2.NotesOS into seg1.NotesOS via re-parenting.
Migration happens BEFORE seg2 is removed.
- TRANSLATION_POLICY_DISCARD / 'discard': Drop seg2's
translations and notes silently.
- TRANSLATION_POLICY_REJECT / 'reject': Raise FP_ParameterError
if seg2 has any non-empty FreeTranslation, LiteralTranslation
(across ALL writing systems), or Notes. Caller must explicitly
pass 'migrate' or 'discard' to proceed.
Returns:
ISegment: seg1 (the survivor segment).
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If either segment is None.
FP_ParameterError: If segments are not adjacent, not in the same
paragraph, or 'reject' policy triggered.
Example:
>>> seg1 = segments[0]
>>> seg2 = segments[1]
>>> merged = project.Segments.MergeSegments(seg1, seg2)
>>> print(project.Segments.GetBaselineText(merged))
In the beginning God created the heavens and the earth.
See Also:
SplitSegment, AppendSentence, ReparseParagraph
Notes:
All wordform analyses on seg2 are lost. Prefer 'migrate' (default)
to preserve translations across the merge.
"""
self._EnsureWriteEnabled()
self._ValidateParam(seg1_or_hvo, "seg1_or_hvo")
self._ValidateParam(seg2_or_hvo, "seg2_or_hvo")
seg1 = self.__GetSegmentObject(seg1_or_hvo)
seg2 = self.__GetSegmentObject(seg2_or_hvo)
# Validate translation_policy early — before any LCM calls — so a
# typo like 'Migrate' raises immediately on a plain mock without needing
# a real SegmentsOS collection.
if translation_policy not in _VALID_TRANSLATION_POLICIES:
raise FP_ParameterError(
f"translation_policy must be one of {_VALID_TRANSLATION_POLICIES!r}, "
f"got {translation_policy!r}"
)
# Compare typed paragraph owners by HVO (issue #521). Raw seg.Owner is
# ICmObject; reference inequality can spuriously fail adjacent segments.
para1 = self._GetTypedOwner(seg1)
para2 = self._GetTypedOwner(seg2)
if para1 is None or para2 is None:
raise FP_ParameterError("Segments have no valid owner paragraph")
if para1.Hvo != para2.Hvo:
raise FP_ParameterError("Segments must be in the same paragraph")
para = para1
# Adjacent indices by HVO (issue #525). SegmentsOS can yield bare
# interface views while __GetSegmentObject returns cast handles.
seg1_hvo = seg1.Hvo
seg2_hvo = seg2.Hvo
idx1 = idx2 = None
for i, seg in enumerate(para.SegmentsOS):
if seg.Hvo == seg1_hvo:
idx1 = i
if seg.Hvo == seg2_hvo:
idx2 = i
if idx1 is None or idx2 is None:
raise FP_ParameterError(
"One or both segments are not in the paragraph SegmentsOS"
)
# Ensure seg1 is the earlier segment
if idx1 > idx2:
seg1, seg2 = seg2, seg1
idx1, idx2 = idx2, idx1
if idx2 != idx1 + 1:
raise FP_ParameterError(
f"Segments must be adjacent; seg1 is at index {idx1}, "
f"seg2 is at index {idx2}"
)
# Enforce translation_policy
if translation_policy == TRANSLATION_POLICY_REJECT:
has_content = False
# Check ALL writing systems in both FreeTranslation and LiteralTranslation,
# not just the default WS, to honour the docstring promise.
# IMultiString.GetStringFromIndex(i) returns (ITsString, ws_int) tuple.
for attr in ("FreeTranslation", "LiteralTranslation"):
ms = getattr(seg2, attr, None)
if ms is None:
continue
for i in range(ms.StringCount):
ts, _ws = ms.GetStringFromIndex(i)
text_val = ts.Text if hasattr(ts, "Text") else None
if text_val:
has_content = True
break
if has_content:
break
# Also check Notes
if not has_content:
notes = list(seg2.NotesOS) if hasattr(seg2, "NotesOS") else []
if notes:
has_content = True
if has_content:
raise FP_ParameterError(
"seg2 has non-empty translations/notes and translation_policy='reject'. "
"Pass translation_policy='migrate' to concatenate into seg1, or "
"'discard' to drop them."
)
with self._TransactionCM("Merge segments"):
if translation_policy == TRANSLATION_POLICY_MIGRATE:
# Migrate FreeTranslation, LiteralTranslation, and Notes for all WS
self.__MigrateTranslations(seg1, seg2)
# TRANSLATION_POLICY_DISCARD: do nothing — seg2's data is lost on removal
# --- Step 1: edit Contents to remove the inter-segment boundary ---
bldr = para.Contents.GetBldr()
# Remove characters from end of seg1 (the terminator) through start of seg2
# seg1.EndOffset is the position just after seg1's last char (includes terminator)
# seg2.BeginOffset is the first char of seg2's text
bldr.Replace(seg1.EndOffset, seg2.BeginOffset, "", None)
# Assign — fires AnalysisAdjuster which shifts seg3+ offsets left
para.Contents = bldr.GetString()
# --- Step 2: remove seg2 from SegmentsOS if it still exists ---
# Membership by HVO (issue #525); same gap as Exists (#523).
for seg in para.SegmentsOS:
if seg.Hvo == seg2.Hvo:
para.SegmentsOS.Remove(seg)
break
return seg1
def __MigrateTranslations(self, seg1, seg2):
"""
Concatenate seg2's FreeTranslation and LiteralTranslation into seg1
per writing system that has content, joined by ' / '. Also moves all
Notes from seg2's NotesOS into seg1's NotesOS (re-parenting ownership).
Args:
seg1: The survivor segment.
seg2: The segment being merged away.
"""
# Collect all WS handles present in either FreeTranslation or LiteralTranslation.
# IMultiString.GetStringFromIndex(i) returns (ITsString, ws_int) tuple.
ws_set = set()
for seg in (seg1, seg2):
for attr in ("FreeTranslation", "LiteralTranslation"):
ms = getattr(seg, attr, None)
if ms is not None:
for i in range(ms.StringCount):
_ts, ws = ms.GetStringFromIndex(i)
ws_set.add(ws)
# The only caller (MergeSegments) already holds a "Merge segments"
# transaction when it calls this helper, so at runtime this bracket
# simply joins that one -- _TransactionCM is nesting-aware, so no
# second undo entry is opened. It is stated here anyway so the
# mutations below are bracketed at the site per D5, and so a future
# caller cannot reach them unbracketed.
with self._TransactionCM("Migrate segment translations"):
for ws in ws_set:
# FreeTranslation
if seg1.FreeTranslation and seg2.FreeTranslation:
t1 = ITsString(seg1.FreeTranslation.get_String(ws)).Text or ""
t2 = ITsString(seg2.FreeTranslation.get_String(ws)).Text or ""
if t2:
merged_text = (t1 + " / " + t2) if t1 else t2
mkstr = TsStringUtils.MakeString(merged_text, ws)
seg1.FreeTranslation.set_String(ws, mkstr)
# LiteralTranslation
if seg1.LiteralTranslation and seg2.LiteralTranslation:
t1 = ITsString(seg1.LiteralTranslation.get_String(ws)).Text or ""
t2 = ITsString(seg2.LiteralTranslation.get_String(ws)).Text or ""
if t2:
merged_text = (t1 + " / " + t2) if t1 else t2
mkstr = TsStringUtils.MakeString(merged_text, ws)
seg1.LiteralTranslation.set_String(ws, mkstr)
# Notes: move all ICmBaseAnnotation objects from seg2.NotesOS to seg1.NotesOS.
# ILcmOwningSequence.MoveTo(srcStart, srcEnd, dest, destStart) re-parents
# ownership without creating new objects.
if hasattr(seg2, "NotesOS") and hasattr(seg1, "NotesOS"):
notes2 = list(seg2.NotesOS)
if notes2:
dest_index = seg1.NotesOS.Count
seg2.NotesOS.MoveTo(0, len(notes2) - 1, seg1.NotesOS, dest_index)
@OperationsMethod
def ReparseParagraph(self, paragraph_or_hvo):
"""
Re-derive segment offsets and wordform analyses for a paragraph by
reassigning para.Contents to itself; FreeTranslation, LiteralTranslation,
and Notes on surviving segments are preserved.
Implementation: assigns para.Contents back to itself. That single setter
assignment fires ContentsSideEffects -> AnalysisAdjuster, which is an
offset reconciler -- it rebuilds AnalysesRS (wordform analyses) but does
NOT touch FreeTranslation, LiteralTranslation, or NotesOS on surviving
segments (those are independently-owned LCM objects with no dependency
on Contents). When the assignment is a self-assignment (the only path
through this method), segment objects survive with identical offsets,
so their translations are never cleared. No regex, no manual
SegmentsOS.Clear(), no factory.Create() calls are needed or used.
Args:
paragraph_or_hvo: The IStTxtPara object or HVO.
Returns:
The freshly rebuilt SegmentsOS collection (ILcmOwningSequence).
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If paragraph_or_hvo is None.
Example:
>>> para = project.Object(para_hvo)
>>> segs = project.Segments.ReparseParagraph(para)
>>> print(f"Rebuilt {segs.Count} segments")
Rebuilt 3 segments
See Also:
SplitSegment, MergeSegments, AppendSentence
Warning:
This destroys all existing wordform analyses (AnalysesRS) on the
paragraph. FreeTranslation, LiteralTranslation, and Notes are NOT
cleared. Use SplitSegment or MergeSegments for non-destructive
structural edits.
"""
self._EnsureWriteEnabled()
self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo")
para = self.__GetParagraphObject(paragraph_or_hvo)
# Snapshot the current Contents and reassign. The setter fires
# ContentsSideEffects -> AnalysisAdjuster which re-derives SegmentsOS
# entirely from the punctuation pattern in the text.
snapshot = para.Contents
with self._TransactionCM("Reparse paragraph segments"):
para.Contents = snapshot
return para.SegmentsOS
@OperationsMethod
def GetOwningParagraph(self, segment_or_hvo):
"""
Get the paragraph that owns a segment.
Retrieves the ``IStTxtPara`` object that contains the segment via the
typed owner chain (issue #521).
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
IStTxtPara: The paragraph that owns the segment.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
FP_ParameterError: If the segment does not exist or has no owner.
Example:
>>> para = project.Paragraphs.Create(text, "Hello world.")
>>> seg = project.Segments.AppendSentence(para, "Second sentence.")
>>> owner = project.Segments.GetOwningParagraph(seg)
>>> assert owner.Hvo == para.Hvo
Notes:
- Accepts segment HVO and raw ``project.Object(hvo)`` views (#275)
- Prefer this over ``segment.Paragraph`` / raw ``segment.Owner``
See Also:
MergeSegments, SplitSegment, SetBaselineText, Delete
"""
segment_obj = self.__GetSegmentObject(segment_or_hvo)
para = self._GetTypedOwner(segment_obj)
if para is None:
raise FP_ParameterError("Segment has no valid owning paragraph")
return para
# ========== QUERY/VALIDATION METHODS ==========
@OperationsMethod
def Exists(self, paragraph_or_hvo, segment_or_hvo):
"""
Check if a segment exists in a paragraph.
Args:
paragraph_or_hvo: The IStTxtPara object or HVO.
segment_or_hvo: The ISegment object or HVO to check for.
Returns:
bool: True if the segment exists in the paragraph, False otherwise.
Raises:
FP_NullParameterError: If paragraph_or_hvo or segment_or_hvo is None.
Example:
>>> para = project.Object(para_hvo)
>>> segment = segments[0]
>>> exists = project.Segments.Exists(para, segment)
>>> print(exists)
True
See Also:
AppendSentence, Delete, GetAll
"""
self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo")
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
para_obj = self.__GetParagraphObject(paragraph_or_hvo)
segment_obj = self.__GetSegmentObject(segment_or_hvo)
# Membership by HVO (issue #523). SegmentsOS yields bare interface
# views while __GetSegmentObject may return cast_to_concrete handles;
# Python ``in`` then false-negates for the same LCM segment.
target_hvo = segment_obj.Hvo
for seg in para_obj.SegmentsOS:
if seg.Hvo == target_hvo:
return True
return False
@OperationsMethod
def GetBeginOffset(self, segment_or_hvo):
"""
Get the beginning character offset of a segment within its paragraph.
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
int: The beginning offset (0-based character position).
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> offset = project.Segments.GetBeginOffset(segment)
>>> print(offset)
0
See Also:
GetEndOffset
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
if hasattr(segment_obj, "BeginOffset"):
return segment_obj.BeginOffset
return 0
@OperationsMethod
def GetEndOffset(self, segment_or_hvo):
"""
Get the ending character offset of a segment within its paragraph.
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
int: The ending offset (0-based character position).
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> offset = project.Segments.GetEndOffset(segment)
>>> print(offset)
45
See Also:
GetBeginOffset
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
if hasattr(segment_obj, "EndOffset"):
return segment_obj.EndOffset
return 0
@OperationsMethod
def IsLabel(self, segment_or_hvo):
"""
Check if a segment is marked as a label (section header).
Args:
segment_or_hvo: The ISegment object or HVO.
Returns:
bool: True if the segment is a label, False otherwise.
Raises:
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> is_header = project.Segments.IsLabel(segment)
>>> print(is_header)
False
See Also:
SetIsLabel
"""
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
if hasattr(segment_obj, "IsLabel"):
return segment_obj.IsLabel
return False
@OperationsMethod
def SetIsLabel(self, segment_or_hvo, is_label):
"""
Set whether a segment is a label (section header).
Args:
segment_or_hvo: The ISegment object or HVO.
is_label: Boolean value - True to mark as label, False for regular segment.
Raises:
FP_ReadOnlyError: If project is not opened with writeEnabled=True.
FP_NullParameterError: If segment_or_hvo is None.
Example:
>>> segment = segments[0]
>>> project.Segments.SetIsLabel(segment, True)
See Also:
IsLabel
"""
self._EnsureWriteEnabled()
self._ValidateParam(segment_or_hvo, "segment_or_hvo")
segment_obj = self.__GetSegmentObject(segment_or_hvo)
# hasattr guard outside the bracket -- its absence is a true no-op.
if hasattr(segment_obj, "IsLabel"):
with self._TransactionCM("Set segment label flag"):
segment_obj.IsLabel = bool(is_label)
@OperationsMethod
def ValidateSegments(self, paragraph_or_hvo):
"""
Validate the integrity of all segments in a paragraph.
Checks that segments are properly structured with valid offsets,
non-overlapping ranges, and complete coverage of the paragraph text.
Args:
paragraph_or_hvo: The IStTxtPara object or HVO.
Returns:
dict: Validation results with keys:
- 'valid': bool - Overall validity
- 'errors': list - List of error messages
- 'warnings': list - List of warning messages
- 'segment_count': int - Number of segments checked
Raises:
FP_NullParameterError: If paragraph_or_hvo is None.
Example:
>>> para = project.Object(para_hvo)
>>> result = project.Segments.ValidateSegments(para)
>>> if result['valid']:
... print("All segments are valid")
... else:
... for error in result['errors']:
... print(f"Error: {error}")
See Also:
ReparseParagraph, GetBeginOffset, GetEndOffset
"""
self._ValidateParam(paragraph_or_hvo, "paragraph_or_hvo")
para_obj = self.__GetParagraphObject(paragraph_or_hvo)
segments_list = list(para_obj.SegmentsOS)
errors = []
warnings = []
if not segments_list:
warnings.append("Paragraph has no segments")
return {"valid": True, "errors": errors, "warnings": warnings, "segment_count": 0}
prev_end = -1
for i, segment in enumerate(segments_list):
if not hasattr(segment, "BeginOffset") or not hasattr(segment, "EndOffset"):
warnings.append(f"Segment {i} missing offset attributes")
continue
begin = segment.BeginOffset
end = segment.EndOffset
if begin < 0 or end < 0:
errors.append(f"Segment {i} has negative offset (begin={begin}, end={end})")
if begin >= end:
errors.append(f"Segment {i} has invalid offsets (begin={begin} >= end={end})")
if prev_end >= 0:
if begin > prev_end + 1:
warnings.append(
f"Gap between segment {i-1} and {i} "
f"(prev_end={prev_end}, begin={begin})"
)
elif begin < prev_end:
errors.append(
f"Overlap between segment {i-1} and {i} "
f"(prev_end={prev_end}, begin={begin})"
)
baseline = self.GetBaselineText(segment)
if not baseline:
warnings.append(f"Segment {i} has empty baseline text")
prev_end = end
valid = len(errors) == 0
return {"valid": valid, "errors": errors, "warnings": warnings, "segment_count": len(segments_list)}
# ========== SYNC INTEGRATION METHODS ==========
@OperationsMethod
def GetSyncableProperties(self, item):
"""
Get all syncable properties of a segment.
Args:
item: The ISegment object.
Returns:
dict: Dictionary of syncable properties with their values.
Example:
>>> props = project.Segments.GetSyncableProperties(segment)
>>> print(props['BaselineText'])
{'en': 'In the beginning...'}
>>> print(props['IsLabel'])
False
>>> print(props['BeginOffset'])
0
Notes:
- MultiString properties: FreeTranslation, LiteralTranslation
- BaselineText is ITsString (single-WS, read-only computed from para Contents)
- Boolean property: IsLabel
- Integer properties: BeginOffset, EndOffset
- Does NOT include analyses (those are children)
"""
props = {}
# BaselineText is ITsString (single-WS, read-only computed from para Contents).
if hasattr(item, "BaselineText") and item.BaselineText:
bt = item.BaselineText
ws_handle = bt.get_Properties(0).GetIntPropValues(1, 0)[0]
props["BaselineText"] = {ws_handle: bt.Text or ""}
# FreeTranslation and LiteralTranslation are genuine IMultiString fields.
if hasattr(item, "FreeTranslation") and item.FreeTranslation:
props["FreeTranslation"] = self.project.GetMultiStringDict(item.FreeTranslation)
if hasattr(item, "LiteralTranslation") and item.LiteralTranslation:
props["LiteralTranslation"] = self.project.GetMultiStringDict(item.LiteralTranslation)
if hasattr(item, "IsLabel"):
props["IsLabel"] = bool(item.IsLabel)
if hasattr(item, "BeginOffset"):
props["BeginOffset"] = int(item.BeginOffset)
if hasattr(item, "EndOffset"):
props["EndOffset"] = int(item.EndOffset)
return props
@OperationsMethod
def CompareTo(self, item1, item2, ops1=None, ops2=None):
"""
Compare two segments for differences.
Args:
item1: First segment object (from project 1)
item2: Second segment object (from project 2)
ops1: Optional SegmentOperations instance for project 1 (defaults to self)
ops2: Optional SegmentOperations instance for project 2 (defaults to self)
Returns:
tuple: (is_different, differences_dict)
- is_different (bool): True if segments differ, False if identical
- differences_dict (dict): Maps property names to (value1, value2) tuples
Example:
>>> is_diff, diffs = ops1.CompareTo(seg1, seg2, ops1, ops2)
>>> if is_diff:
... for prop, (val1, val2) in diffs.items():
... print(f"{prop}: {val1} != {val2}")
"""
if ops1 is None:
ops1 = self
if ops2 is None:
ops2 = self
props1 = ops1.GetSyncableProperties(item1)
props2 = ops2.GetSyncableProperties(item2)
differences = {}
all_keys = set(props1.keys()) | set(props2.keys())
for key in all_keys:
val1 = props1.get(key)
val2 = props2.get(key)
# Inline comparison: FLExProject has no _CompareValues member
# (calling it raised AttributeError on every compare; same
# fix as MediaOperations.CompareTo).
if val1 != val2:
differences[key] = (val1, val2)
is_different = len(differences) > 0
return (is_different, differences)